From 868b024dbe43f5cd6df619d9bbdfbf25ebdaeb50 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:45:50 -0700 Subject: [PATCH] Fix localized strings getting stuck on language switch (#5150) * Fix localized strings getting stuck on language switch Replace static module-level translation evaluations with property getters and i18n hooks to ensure UI elements update dynamically when the active language changes. - Convert static configuration properties to dynamic getters. - Call `useTranslation()` to trigger re-renders on language updates. - Recreate Tiptap editor extensions when the active language changes to refresh frozen placeholder configurations. * Evaluate localized strings dynamically in static UI structures - Use ES6 getters with `translate()` in static object maps (such as SSH connection status, usage ranges, and feature tour copy) to evaluate strings on demand when the language changes. - Add the `useTranslation()` hook to `LinearIssueMarkdownToolbar` so it subscribes directly to translation updates. - Extract markdown slash command primitives to a separate file to keep catalog configuration clean. --------- Co-authored-by: Orca --- .../LinearIssueMarkdownDescriptionEditor.tsx | 363 +++---------- .../components/LinearIssueMarkdownToolbar.tsx | 267 ++++++++++ .../components/NewWorkspaceComposerCard.tsx | 61 ++- .../components/browser-pane/BrowserPane.tsx | 8 +- .../components/editor/EditorViewToggle.tsx | 36 +- .../editor/RichMarkdownCodeBlock.tsx | 98 +++- .../components/editor/markdown-rich-mode.ts | 30 +- .../rich-markdown-slash-command-catalog.tsx | 406 ++++++++++++++ .../rich-markdown-slash-command-primitives.ts | 117 ++++ .../editor/rich-markdown-slash-commands.tsx | 347 +----------- .../AgentCapabilitiesSetupAction.tsx | 60 ++- .../feature-wall/BrowserAnimatedVisual.tsx | 43 +- .../feature-wall/FeatureTourPreview.tsx | 203 ++++--- .../ReviewPRViewAnimatedVisual.tsx | 377 +++++-------- .../feature-wall/TasksAnimatedVisual.tsx | 10 +- .../feature-wall/feature-tour-preview-copy.ts | 109 ++++ .../feature-wall/review-pr-view-animation.ts | 261 +++++++++ .../github/github-rate-limit-display.tsx | 33 +- .../components/mobile/mobile-platform-copy.ts | 20 +- .../new-workspace/SmartWorkspaceNameField.tsx | 113 +--- .../smart-workspace-localized-options.test.ts | 61 +++ .../smart-workspace-localized-options.tsx | 89 ++++ .../onboarding/FeatureSetupChecklist.tsx | 57 +- .../components/onboarding/OnboardingFlow.tsx | 77 ++- .../OnboardingSkipConfirmationDialog.tsx | 20 +- src/renderer/src/components/pet/pet-models.ts | 12 +- .../components/settings/ComputerUsePane.tsx | 28 +- .../settings/DeveloperPermissionsPane.tsx | 153 ++++-- ...orySourceControlAiHostedReviewDefaults.tsx | 40 +- .../src/components/settings/Settings.tsx | 38 +- .../SourceControlAiActionRecipeDefaults.tsx | 87 +-- .../src/components/settings/SshTargetCard.tsx | 4 +- .../src/components/settings/TasksPane.tsx | 56 +- .../settings/TerminalWindowSection.tsx | 503 ++++++++++++++++-- .../settings/mobile-auto-restore-options.ts | 22 +- .../sidebar/SidebarWorkspaceOptionsMenu.tsx | 259 +++++++-- .../sidebar/SshDisconnectedDialog.tsx | 54 +- .../sidebar/worktree-list-groups.ts | 24 +- .../components/stats/ClaudeUsageDetails.tsx | 172 ++++++ .../src/components/stats/ClaudeUsagePane.tsx | 102 +--- .../components/stats/CodexUsageDetails.tsx | 183 +++++++ .../src/components/stats/CodexUsagePane.tsx | 105 +--- .../components/stats/OpenCodeUsageDetails.tsx | 186 +++++++ .../components/stats/OpenCodeUsagePane.tsx | 118 ++-- .../src/components/stats/StatsPane.tsx | 28 +- .../tab-bar/SortableTabContextMenu.tsx | 40 +- .../src/i18n/no-top-level-translate.test.ts | 82 +++ src/renderer/src/main.tsx | 18 +- src/renderer/src/web/main.tsx | 36 +- 49 files changed, 3844 insertions(+), 1772 deletions(-) create mode 100644 src/renderer/src/components/LinearIssueMarkdownToolbar.tsx create mode 100644 src/renderer/src/components/editor/rich-markdown-slash-command-catalog.tsx create mode 100644 src/renderer/src/components/editor/rich-markdown-slash-command-primitives.ts create mode 100644 src/renderer/src/components/feature-wall/feature-tour-preview-copy.ts create mode 100644 src/renderer/src/components/feature-wall/review-pr-view-animation.ts create mode 100644 src/renderer/src/components/new-workspace/smart-workspace-localized-options.test.ts create mode 100644 src/renderer/src/components/new-workspace/smart-workspace-localized-options.tsx create mode 100644 src/renderer/src/components/stats/ClaudeUsageDetails.tsx create mode 100644 src/renderer/src/components/stats/CodexUsageDetails.tsx create mode 100644 src/renderer/src/components/stats/OpenCodeUsageDetails.tsx create mode 100644 src/renderer/src/i18n/no-top-level-translate.test.ts diff --git a/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx b/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx index dc03f202875..b63dc248665 100644 --- a/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx +++ b/src/renderer/src/components/LinearIssueMarkdownDescriptionEditor.tsx @@ -1,26 +1,13 @@ -import React, { useCallback, useEffect, useRef } from 'react' +import React, { useEffect, useMemo, useRef } from 'react' +import { useTranslation } from 'react-i18next' import { EditorContent, useEditor } from '@tiptap/react' import type { Editor } from '@tiptap/react' import Placeholder from '@tiptap/extension-placeholder' -import { - Bold, - Code, - Heading1, - Heading2, - Italic, - Link as LinkIcon, - List, - ListOrdered, - ListTodo, - LoaderCircle, - Pilcrow, - Quote, - Strikethrough -} from 'lucide-react' +import { LoaderCircle } from 'lucide-react' import { createRichMarkdownExtensions } from '@/components/editor/rich-markdown-extensions' import { encodeRawMarkdownHtmlForRichEditor } from '@/components/editor/raw-markdown-html' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { LinearIssueMarkdownToolbar } from '@/components/LinearIssueMarkdownToolbar' import { isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' @@ -34,258 +21,17 @@ type LinearIssueMarkdownDescriptionEditorProps = { submitShortcutLabel: string } -type LinearIssueMarkdownToolbarButtonProps = { - active?: boolean - disabled?: boolean - label: string - onClick: () => void - children: React.ReactNode -} - -const linearIssueMarkdownExtensions = [ - ...createRichMarkdownExtensions(), - Placeholder.configure({ - placeholder: translate( - 'auto.components.LinearIssueMarkdownDescriptionEditor.4f2fddc2b7', - 'No description provided.' - ) - }) -] - -function LinearIssueMarkdownToolbarButton({ - active = false, - disabled = false, - label, - onClick, - children -}: LinearIssueMarkdownToolbarButtonProps): React.JSX.Element { - return ( - - - - - - {label} - - - ) -} - -function LinearIssueMarkdownToolbarSeparator(): React.JSX.Element { - return
-} - -function applyLinearIssueLink(editor: Editor | null): void { - if (!editor) { - return - } - if (editor.isActive('link')) { - editor.chain().focus().unsetLink().run() - return - } - - const previousHref = editor.getAttributes('link').href as string | undefined - const href = window.prompt( - translate('auto.components.LinearIssueMarkdownDescriptionEditor.5c16ec8f14', 'Link URL'), - previousHref ?? '' - ) - if (href === null) { - editor.chain().focus().run() - return - } - - const trimmed = href.trim() - if (!trimmed) { - editor.chain().focus().unsetLink().run() - return - } - - editor.chain().focus().extendMarkRange('link').setLink({ href: trimmed }).run() -} - -function LinearIssueMarkdownToolbar({ - editor, - disabled -}: { - editor: Editor | null - disabled: boolean -}): React.JSX.Element { - const runCommand = useCallback( - (command: (editor: Editor) => void) => { - if (!editor || disabled) { - return - } - command(editor) - }, - [disabled, editor] - ) - - return ( -
- runCommand((nextEditor) => nextEditor.chain().focus().setParagraph().run())} - > - - - - runCommand((nextEditor) => nextEditor.chain().focus().toggleHeading({ level: 1 }).run()) - } - > - - - - runCommand((nextEditor) => nextEditor.chain().focus().toggleHeading({ level: 2 }).run()) - } - > - - - - runCommand((nextEditor) => nextEditor.chain().focus().toggleBold().run())} - > - - - runCommand((nextEditor) => nextEditor.chain().focus().toggleItalic().run())} - > - - - runCommand((nextEditor) => nextEditor.chain().focus().toggleStrike().run())} - > - - - runCommand((nextEditor) => nextEditor.chain().focus().toggleCode().run())} - > - - - - - runCommand((nextEditor) => nextEditor.chain().focus().toggleBulletList().run()) - } - > - - - - runCommand((nextEditor) => nextEditor.chain().focus().toggleOrderedList().run()) - } - > - - - - runCommand((nextEditor) => nextEditor.chain().focus().toggleTaskList().run()) - } - > - - - - - runCommand((nextEditor) => nextEditor.chain().focus().toggleBlockquote().run()) - } - > - - - runCommand(applyLinearIssueLink)} - > - - -
- ) +function createLinearIssueMarkdownExtensions() { + const extensions = createRichMarkdownExtensions() + return [ + ...extensions, + Placeholder.configure({ + placeholder: translate( + 'auto.components.LinearIssueMarkdownDescriptionEditor.4f2fddc2b7', + 'No description provided.' + ) + }) + ] } export function LinearIssueMarkdownDescriptionEditor({ @@ -296,46 +42,57 @@ export function LinearIssueMarkdownDescriptionEditor({ disabled, submitShortcutLabel }: LinearIssueMarkdownDescriptionEditorProps): React.JSX.Element { + const { i18n } = useTranslation() + const language = i18n.resolvedLanguage ?? i18n.language const lastEditorMarkdownRef = useRef(value) const editorRef = useRef(null) + const linearIssueMarkdownExtensions = useMemo(() => { + // Why: Tiptap freezes extension options when the editor is created; the + // language value is the recreation key for translated extension options. + void language + return createLinearIssueMarkdownExtensions() + }, [language]) - const editor = useEditor({ - immediatelyRender: false, - extensions: linearIssueMarkdownExtensions, - content: encodeRawMarkdownHtmlForRichEditor(value), - contentType: 'markdown', - editable: !disabled, - editorProps: { - attributes: { - class: 'rich-markdown-editor', - spellcheck: 'true', - 'aria-label': 'Issue description' - }, - handleKeyDown: (_view, event) => { - if (!isScreenSubmitShortcut(event)) { - return false + const editor = useEditor( + { + immediatelyRender: false, + extensions: linearIssueMarkdownExtensions, + content: encodeRawMarkdownHtmlForRichEditor(value), + contentType: 'markdown', + editable: !disabled, + editorProps: { + attributes: { + class: 'rich-markdown-editor', + spellcheck: 'true', + 'aria-label': 'Issue description' + }, + handleKeyDown: (_view, event) => { + if (!isScreenSubmitShortcut(event)) { + return false + } + event.preventDefault() + editorRef.current?.commands.blur() + return true } - event.preventDefault() - editorRef.current?.commands.blur() - return true + }, + onFocus: () => { + window.api.ui.setMarkdownEditorFocused(true) + }, + onBlur: ({ editor: nextEditor }) => { + window.api.ui.setMarkdownEditorFocused(false) + const nextValue = nextEditor.getMarkdown() + lastEditorMarkdownRef.current = nextValue + onChange(nextValue) + onSave(nextValue) + }, + onUpdate: ({ editor: nextEditor }) => { + const nextValue = nextEditor.getMarkdown() + lastEditorMarkdownRef.current = nextValue + onChange(nextValue) } }, - onFocus: () => { - window.api.ui.setMarkdownEditorFocused(true) - }, - onBlur: ({ editor: nextEditor }) => { - window.api.ui.setMarkdownEditorFocused(false) - const nextValue = nextEditor.getMarkdown() - lastEditorMarkdownRef.current = nextValue - onChange(nextValue) - onSave(nextValue) - }, - onUpdate: ({ editor: nextEditor }) => { - const nextValue = nextEditor.getMarkdown() - lastEditorMarkdownRef.current = nextValue - onChange(nextValue) - } - }) + [language] + ) useEffect(() => { editorRef.current = editor diff --git a/src/renderer/src/components/LinearIssueMarkdownToolbar.tsx b/src/renderer/src/components/LinearIssueMarkdownToolbar.tsx new file mode 100644 index 00000000000..8ecdf5673c1 --- /dev/null +++ b/src/renderer/src/components/LinearIssueMarkdownToolbar.tsx @@ -0,0 +1,267 @@ +import React, { useCallback } from 'react' +import type { Editor } from '@tiptap/react' +import { useTranslation } from 'react-i18next' +import { + Bold, + Code, + Heading1, + Heading2, + Italic, + Link as LinkIcon, + List, + ListOrdered, + ListTodo, + Pilcrow, + Quote, + Strikethrough +} from 'lucide-react' + +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' + +type LinearIssueMarkdownToolbarButtonProps = { + active?: boolean + disabled?: boolean + label: string + onClick: () => void + children: React.ReactNode +} + +function LinearIssueMarkdownToolbarButton({ + active = false, + disabled = false, + label, + onClick, + children +}: LinearIssueMarkdownToolbarButtonProps): React.JSX.Element { + return ( + + + + + + {label} + + + ) +} + +function LinearIssueMarkdownToolbarSeparator(): React.JSX.Element { + return
+} + +function applyLinearIssueLink(editor: Editor | null): void { + if (!editor) { + return + } + if (editor.isActive('link')) { + editor.chain().focus().unsetLink().run() + return + } + + const previousHref = editor.getAttributes('link').href as string | undefined + const href = window.prompt( + translate('auto.components.LinearIssueMarkdownDescriptionEditor.5c16ec8f14', 'Link URL'), + previousHref ?? '' + ) + if (href === null) { + editor.chain().focus().run() + return + } + + const trimmed = href.trim() + if (!trimmed) { + editor.chain().focus().unsetLink().run() + return + } + + editor.chain().focus().extendMarkRange('link').setLink({ href: trimmed }).run() +} + +export function LinearIssueMarkdownToolbar({ + editor, + disabled +}: { + editor: Editor | null + disabled: boolean +}): React.JSX.Element { + // Why: this toolbar can outlive editor recreation, so subscribe directly to language changes. + useTranslation() + const runCommand = useCallback( + (command: (editor: Editor) => void) => { + if (!editor || disabled) { + return + } + command(editor) + }, + [disabled, editor] + ) + + return ( +
+ runCommand((nextEditor) => nextEditor.chain().focus().setParagraph().run())} + > + + + + runCommand((nextEditor) => nextEditor.chain().focus().toggleHeading({ level: 1 }).run()) + } + > + + + + runCommand((nextEditor) => nextEditor.chain().focus().toggleHeading({ level: 2 }).run()) + } + > + + + + runCommand((nextEditor) => nextEditor.chain().focus().toggleBold().run())} + > + + + runCommand((nextEditor) => nextEditor.chain().focus().toggleItalic().run())} + > + + + runCommand((nextEditor) => nextEditor.chain().focus().toggleStrike().run())} + > + + + runCommand((nextEditor) => nextEditor.chain().focus().toggleCode().run())} + > + + + + + runCommand((nextEditor) => nextEditor.chain().focus().toggleBulletList().run()) + } + > + + + + runCommand((nextEditor) => nextEditor.chain().focus().toggleOrderedList().run()) + } + > + + + + runCommand((nextEditor) => nextEditor.chain().focus().toggleTaskList().run()) + } + > + + + + + runCommand((nextEditor) => nextEditor.chain().focus().toggleBlockquote().run()) + } + > + + + runCommand(applyLinearIssueLink)} + > + + +
+ ) +} diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 268110e46f1..70b29ba4cf6 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -2,6 +2,7 @@ composer card markup together so the inline and modal variants share one UI surface without splitting the controlled form into hard-to-follow fragments. */ import React from 'react' +import { useTranslation } from 'react-i18next' import { AlertTriangle, Check, @@ -99,15 +100,50 @@ type NewWorkspaceComposerCardProps = { sparseControlsEnabled?: boolean } -const SSH_STATUS_LABELS: Record = { - disconnected: 'SSH not connected', - connecting: 'Connecting SSH...', - 'auth-failed': 'SSH authentication failed', - 'deploying-relay': 'Preparing SSH connection...', - connected: 'Connected', - reconnecting: 'Reconnecting SSH...', - 'reconnection-failed': 'SSH reconnection failed', - error: translate('auto.components.NewWorkspaceComposerCard.a239038146', 'SSH connection error') +const SSH_STATUS_LABELS: Partial> = { + get disconnected() { + return translate( + 'auto.components.NewWorkspaceComposerCard.sshNotConnected', + 'SSH not connected' + ) + }, + get connecting() { + return translate('auto.components.NewWorkspaceComposerCard.connectingSsh', 'Connecting SSH...') + }, + get 'auth-failed'() { + return translate( + 'auto.components.NewWorkspaceComposerCard.sshAuthenticationFailed', + 'SSH authentication failed' + ) + }, + get 'deploying-relay'() { + return translate( + 'auto.components.NewWorkspaceComposerCard.preparingSshConnection', + 'Preparing SSH connection...' + ) + }, + get connected() { + return translate('auto.components.NewWorkspaceComposerCard.connected', 'Connected') + }, + get reconnecting() { + return translate( + 'auto.components.NewWorkspaceComposerCard.reconnectingSsh', + 'Reconnecting SSH...' + ) + }, + get 'reconnection-failed'() { + return translate( + 'auto.components.NewWorkspaceComposerCard.sshReconnectionFailed', + 'SSH reconnection failed' + ) + }, + get error() { + return translate('auto.components.NewWorkspaceComposerCard.a239038146', 'SSH connection error') + } +} + +function getSshStatusLabel(status: SshConnectionStatus): string { + return SSH_STATUS_LABELS[status] ?? status } function SetupCommandPreview({ @@ -284,6 +320,9 @@ export default function NewWorkspaceComposerCard({ onSparseSelectPreset, sparseControlsEnabled = true }: NewWorkspaceComposerCardProps): React.JSX.Element { + // Why: this form uses the lightweight translate() helper directly; subscribe + // so an already-open create dialog repaints when the UI language changes. + useTranslation() const { isFileDragOver, dragHandlers } = useComposerFileDragOver() const openModal = useAppStore((s) => s.openModal) const activeModal = useAppStore((s) => s.activeModal) @@ -297,8 +336,8 @@ export default function NewWorkspaceComposerCard({ return repo?.displayName ?? repo?.path ?? 'This project' }, [eligibleRepos, repoId]) const sshStatusLabel = selectedRepoSshStatus - ? SSH_STATUS_LABELS[selectedRepoSshStatus] - : 'Not connected' + ? getSshStatusLabel(selectedRepoSshStatus) + : translate('auto.components.NewWorkspaceComposerCard.notConnected', 'Not connected') const connectButtonLabel = selectedRepoSshStatus === 'disconnected' || selectedRepoSshStatus === null ? 'Connect' diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index 74ae96ee901..a16377b9f68 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -188,12 +188,16 @@ type BrowserOverlayAnchor = { const BROWSER_ANNOTATION_INTENT_OPTIONS = [ { value: 'change', - label: translate('auto.components.browser.pane.BrowserPane.143204e423', 'Change'), + get label() { + return translate('auto.components.browser.pane.BrowserPane.143204e423', 'Change') + }, icon: PencilLine }, { value: 'question', - label: translate('auto.components.browser.pane.BrowserPane.b5ba6085de', 'Question'), + get label() { + return translate('auto.components.browser.pane.BrowserPane.b5ba6085de', 'Question') + }, icon: MessageCircleQuestionMark } ] as const diff --git a/src/renderer/src/components/editor/EditorViewToggle.tsx b/src/renderer/src/components/editor/EditorViewToggle.tsx index 82b07c3a224..2a097af088a 100644 --- a/src/renderer/src/components/editor/EditorViewToggle.tsx +++ b/src/renderer/src/components/editor/EditorViewToggle.tsx @@ -1,4 +1,5 @@ import React from 'react' +import { useTranslation } from 'react-i18next' import { Code, Eye, @@ -27,29 +28,41 @@ type ViewModeMetadata = { label: string; icon: LucideIcon; title?: string } const DEFAULT_VIEW_MODE_METADATA: Record = { source: { - label: translate('auto.components.editor.EditorViewToggle.4d6ccb7ba6', 'Source'), + get label() { + return translate('auto.components.editor.EditorViewToggle.4d6ccb7ba6', 'Source') + }, icon: Code }, rich: { - label: translate('auto.components.editor.EditorViewToggle.aff15f94f5', 'Rich Editor'), + get label() { + return translate('auto.components.editor.EditorViewToggle.aff15f94f5', 'Rich Editor') + }, icon: Pencil }, preview: { - label: translate('auto.components.editor.EditorViewToggle.0d193dc03c', 'Preview'), + get label() { + return translate('auto.components.editor.EditorViewToggle.0d193dc03c', 'Preview') + }, icon: Eye }, edit: { - label: translate('auto.components.editor.EditorViewToggle.ac3bb87913', 'Edit'), + get label() { + return translate('auto.components.editor.EditorViewToggle.ac3bb87913', 'Edit') + }, icon: FileText }, changes: { - label: translate('auto.components.editor.EditorViewToggle.4837f3f578', 'Changes'), + get label() { + return translate('auto.components.editor.EditorViewToggle.4837f3f578', 'Changes') + }, icon: GitCompareArrows, // Why: "Changes" collides with the Source Control sidebar's "Branch // Changes" section, which diffs against the base ref. This toggle shows // uncommitted changes (working tree vs HEAD), so disambiguate in the // hover title without repeating the button label. - title: translate('auto.components.editor.EditorViewToggle.167f45888c', 'Uncommitted changes') + get title() { + return translate('auto.components.editor.EditorViewToggle.167f45888c', 'Uncommitted changes') + } } } @@ -58,14 +71,18 @@ const DEFAULT_VIEW_MODE_METADATA: Record = // which we don't offer, so callers can override the per-mode presentation. export const CSV_VIEW_MODE_METADATA: Partial> = { rich: { - label: translate('auto.components.editor.EditorViewToggle.e408aa9cd5', 'Table'), + get label() { + return translate('auto.components.editor.EditorViewToggle.e408aa9cd5', 'Table') + }, icon: TableIcon } } export const NOTEBOOK_VIEW_MODE_METADATA: Partial> = { rich: { - label: translate('auto.components.editor.EditorViewToggle.b3410cd5e0', 'Notebook'), + get label() { + return translate('auto.components.editor.EditorViewToggle.b3410cd5e0', 'Notebook') + }, icon: NotebookText } } @@ -83,6 +100,9 @@ export default function EditorViewToggle({ onChange, metadataOverride }: EditorViewToggleProps): React.JSX.Element { + // Why: metadata labels are lightweight getters, so subscribe this compact + // control to repaint when the active language changes. + useTranslation() return ( (null) diff --git a/src/renderer/src/components/editor/markdown-rich-mode.ts b/src/renderer/src/components/editor/markdown-rich-mode.ts index 2cd1aebf7d0..a6ca7dfdfe2 100644 --- a/src/renderer/src/components/editor/markdown-rich-mode.ts +++ b/src/renderer/src/components/editor/markdown-rich-mode.ts @@ -17,10 +17,12 @@ type UnsupportedMatch = { const UNSUPPORTED_PATTERNS: UnsupportedMatch[] = [ { reason: 'html-or-jsx', - message: translate( - 'auto.components.editor.markdown.rich.mode.57128b73e1', - 'Editable only in code mode because this file contains HTML, JSX, or MDX.' - ), + get message() { + return translate( + 'auto.components.editor.markdown.rich.mode.57128b73e1', + 'Editable only in code mode because this file contains HTML, JSX, or MDX.' + ) + }, // Why: the rich editor preserves common embedded markup via placeholder // tokens before parsing, but any HTML shape that still fails round-trip // must fall back instead of risking silent source corruption. @@ -28,18 +30,22 @@ const UNSUPPORTED_PATTERNS: UnsupportedMatch[] = [ }, { reason: 'reference-links', - message: translate( - 'auto.components.editor.markdown.rich.mode.2fd2b44073', - 'Editable only in code mode because this file contains reference-style links.' - ), + get message() { + return translate( + 'auto.components.editor.markdown.rich.mode.2fd2b44073', + 'Editable only in code mode because this file contains reference-style links.' + ) + }, pattern: /^\[[^\]]+\]:\s+\S+/m }, { reason: 'footnotes', - message: translate( - 'auto.components.editor.markdown.rich.mode.7a8ce7c7da', - 'Editable only in code mode because this file contains footnotes.' - ), + get message() { + return translate( + 'auto.components.editor.markdown.rich.mode.7a8ce7c7da', + 'Editable only in code mode because this file contains footnotes.' + ) + }, pattern: /^\[\^[^\]]+\]:\s+/m } ] diff --git a/src/renderer/src/components/editor/rich-markdown-slash-command-catalog.tsx b/src/renderer/src/components/editor/rich-markdown-slash-command-catalog.tsx new file mode 100644 index 00000000000..889ec4e1039 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-slash-command-catalog.tsx @@ -0,0 +1,406 @@ +import type {} from '@tiptap/extension-mathematics' +import { + ChevronRight, + Heading1, + Heading2, + Heading3, + ImageIcon, + List, + ListOrdered, + Quote, + Sigma, + Table2, + Workflow +} from 'lucide-react' +import { translate } from '@/i18n/i18n' + +import { + icon, + insertCodeBlock, + insertTextWithSelection, + insertToggle, + textIcon, + type SlashCommand +} from './rich-markdown-slash-command-primitives' + +export type { + SlashCommand, + SlashCommandGroup, + SlashCommandIcon, + SlashCommandId, + SlashMenuState +} from './rich-markdown-slash-command-primitives' + +export const slashCommands: SlashCommand[] = [ + { + id: 'heading-1', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.e66e7f04c6', + 'Heading 1' + ) + }, + aliases: ['h1', 'title'], + icon: icon(Heading1), + group: 'Headings', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.570611864e', + 'Large section heading.' + ) + }, + run: (editor) => { + // Use setHeading (not toggleHeading) so the slash command is idempotent — + // invoking "/h1" on an existing H1 should keep it as H1, not revert to paragraph. + editor.chain().focus().setHeading({ level: 1 }).run() + } + }, + { + id: 'toggle-h1', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.41482b15ce', + 'Toggle Heading 1' + ) + }, + aliases: ['toggle-h1', 'toggle heading', 'details heading', 'collapse heading'], + icon: icon(ChevronRight), + group: 'Headings', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.3294a2c0cc', + 'Create a collapsible section with a large heading summary.' + ) + }, + run: (editor) => { + insertToggle(editor, 'heading-1') + } + }, + { + id: 'heading-2', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.c209a116b7', + 'Heading 2' + ) + }, + aliases: ['h2'], + icon: icon(Heading2), + group: 'Headings', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.45cf7ceb3f', + 'Medium section heading.' + ) + }, + run: (editor) => { + // Use setHeading (not toggleHeading) so the slash command is idempotent — + // invoking "/h2" on an existing H2 should keep it as H2, not revert to paragraph. + editor.chain().focus().setHeading({ level: 2 }).run() + } + }, + { + id: 'heading-3', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.30566ee962', + 'Heading 3' + ) + }, + aliases: ['h3'], + icon: icon(Heading3), + group: 'Headings', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.4920740259', + 'Small section heading.' + ) + }, + run: (editor) => { + // Use setHeading (not toggleHeading) so the slash command is idempotent — + // invoking "/h3" on an existing H3 should keep it as H3, not revert to paragraph. + editor.chain().focus().setHeading({ level: 3 }).run() + } + }, + { + id: 'blockquote', + get label() { + return translate('auto.components.editor.rich.markdown.slash.commands.c4c775778b', 'Quote') + }, + aliases: ['quote', 'blockquote'], + icon: icon(Quote), + group: 'Basic blocks', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.6a3def14de', + 'Insert a blockquote.' + ) + }, + run: (editor) => { + editor.chain().focus().toggleBlockquote().run() + } + }, + { + id: 'ordered-list', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.ed4cf0ebce', + 'Numbered List' + ) + }, + aliases: ['ordered', 'ol', 'numbered'], + icon: icon(ListOrdered), + group: 'Basic blocks', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.8e00aba296', + 'Create an ordered list.' + ) + }, + run: (editor) => { + editor.chain().focus().toggleOrderedList().run() + } + }, + { + id: 'bullet-list', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.56ff3237e7', + 'Bullet List' + ) + }, + aliases: ['bullet', 'ul', 'list'], + icon: icon(List), + group: 'Basic blocks', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.c9b9e826b8', + 'Create an unordered list.' + ) + }, + run: (editor) => { + editor.chain().focus().toggleBulletList().run() + } + }, + { + id: 'task-list', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.d0d2cdfbdb', + 'Check List' + ) + }, + aliases: ['todo', 'task', 'checkbox'], + icon: icon(List), + group: 'Basic blocks', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.d766f44867', + 'Create a checklist.' + ) + }, + run: (editor) => { + editor.chain().focus().toggleTaskList().run() + } + }, + { + id: 'text', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.58abdb9d41', + 'Paragraph' + ) + }, + aliases: ['paragraph', 'plain'], + icon: icon(List), + group: 'Basic blocks', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.9a7fe896dc', + 'Start a normal paragraph.' + ) + }, + run: (editor) => { + editor.chain().focus().setParagraph().run() + } + }, + { + id: 'toggle-text', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.f82c78a2ee', + 'Toggle Text' + ) + }, + aliases: ['toggle', 'details', 'collapse', 'toggle-text'], + icon: icon(ChevronRight), + group: 'Basic blocks', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.972ef9aeea', + 'Create a collapsible text section.' + ) + }, + run: (editor) => { + insertToggle(editor) + } + }, + { + id: 'code-block', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.624b50cf25', + 'Code Block' + ) + }, + aliases: ['code', 'snippet'], + icon: icon(List), + group: 'Basic blocks', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.89e327e054', + 'Insert a fenced code block.' + ) + }, + run: (editor) => { + editor.chain().focus().toggleCodeBlock().run() + } + }, + { + id: 'divider', + get label() { + return translate('auto.components.editor.rich.markdown.slash.commands.ae8377cf6b', 'Divider') + }, + aliases: ['divider', 'rule', 'hr'], + icon: icon(List), + group: 'Basic blocks', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.fae45ef4d3', + 'Insert a horizontal rule.' + ) + }, + run: (editor) => { + editor.chain().focus().setHorizontalRule().run() + } + }, + { + id: 'table', + get label() { + return translate('auto.components.editor.rich.markdown.slash.commands.19ea597868', 'Table') + }, + aliases: ['grid', 'columns', 'rows'], + icon: icon(Table2), + group: 'Advanced', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.67faab829b', + 'Insert a 3x3 markdown table.' + ) + }, + run: (editor) => { + editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() + } + }, + { + id: 'mermaid', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.e516d3f6e3', + 'Mermaid Diagram' + ) + }, + aliases: ['diagram', 'flowchart', 'chart', 'graph'], + icon: icon(Workflow), + group: 'Advanced', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.0ed9a7b38c', + 'Insert a Mermaid fenced block.' + ) + }, + run: (editor) => { + insertCodeBlock(editor, 'mermaid', 'graph TD\n A[Start] --> B[End]') + } + }, + { + id: 'inline-math', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.2bf5544faf', + 'Inline Math' + ) + }, + aliases: ['math', 'latex', 'equation', 'formula'], + icon: icon(Sigma), + group: 'Advanced', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.565907cf7a', + 'Insert inline LaTeX math.' + ) + }, + run: (editor) => { + editor.commands.insertInlineMath({ latex: 'x' }) + } + }, + { + id: 'math-block', + get label() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.6993a38ad1', + 'Math Block' + ) + }, + aliases: ['display math', 'latex block', 'equation block'], + icon: icon(Sigma), + group: 'Advanced', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.ae7d0f3f37', + 'Insert display LaTeX math.' + ) + }, + run: (editor) => { + editor.commands.insertBlockMath({ latex: 'x' }) + } + }, + { + id: 'image', + get label() { + return translate('auto.components.editor.rich.markdown.slash.commands.572be8e524', 'Image') + }, + aliases: ['image', 'img'], + icon: icon(ImageIcon), + group: 'Media', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.3324eb391a', + 'Insert an image from your computer.' + ) + }, + // Why: window.prompt() is not supported in Electron's renderer process, + // so image URL input is handled by an inline input bar in RichMarkdownEditor. + run: (editor) => { + editor.chain().focus().run() + } + }, + { + id: 'emoji', + get label() { + return translate('auto.components.editor.rich.markdown.slash.commands.8a30cbaeca', 'Emoji') + }, + aliases: ['smile', 'reaction', 'icon'], + icon: textIcon('🙂'), + group: 'Others', + get description() { + return translate( + 'auto.components.editor.rich.markdown.slash.commands.07e1b32396', + 'Insert a plain Unicode emoji.' + ) + }, + run: (editor) => { + insertTextWithSelection(editor, '🙂') + } + } +] diff --git a/src/renderer/src/components/editor/rich-markdown-slash-command-primitives.ts b/src/renderer/src/components/editor/rich-markdown-slash-command-primitives.ts new file mode 100644 index 00000000000..2ad2dd822bc --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-slash-command-primitives.ts @@ -0,0 +1,117 @@ +import type React from 'react' +import type { Editor } from '@tiptap/react' +import { TextSelection } from '@tiptap/pm/state' + +export type SlashMenuState = { + query: string + from: number + to: number + left: number + top: number +} + +export type SlashCommandId = + | 'text' + | 'toggle-text' + | 'heading-1' + | 'toggle-h1' + | 'heading-2' + | 'heading-3' + | 'task-list' + | 'bullet-list' + | 'ordered-list' + | 'blockquote' + | 'code-block' + | 'divider' + | 'image' + | 'table' + | 'mermaid' + | 'inline-math' + | 'math-block' + | 'emoji' + +export type SlashCommandIcon = + | { kind: 'component'; component: React.ComponentType<{ className?: string }> } + | { kind: 'text'; value: string } + +export type SlashCommandGroup = 'Headings' | 'Basic blocks' | 'Advanced' | 'Media' | 'Others' + +export type SlashCommand = { + id: SlashCommandId + label: string + aliases: string[] + icon: SlashCommandIcon + group: SlashCommandGroup + description: string + run: (editor: Editor) => void +} + +export function icon(component: React.ComponentType<{ className?: string }>): SlashCommandIcon { + return { kind: 'component', component } +} + +export function textIcon(value: string): SlashCommandIcon { + return { kind: 'text', value } +} + +export function insertTextWithSelection( + editor: Editor, + text: string, + selectionStartOffset?: number, + selectionEndOffset = selectionStartOffset +): void { + editor.commands.command(({ state, dispatch }) => { + const from = state.selection.from + const tr = state.tr.insertText(text, from, state.selection.to) + + if (selectionStartOffset !== undefined) { + const selectionFrom = from + selectionStartOffset + const selectionTo = from + (selectionEndOffset ?? selectionStartOffset) + tr.setSelection(TextSelection.create(tr.doc, selectionFrom, selectionTo)) + } + + dispatch?.(tr.scrollIntoView()) + return true + }) +} + +export function insertCodeBlock(editor: Editor, language: string, text: string): void { + editor.commands.command(({ state, dispatch }) => { + const codeBlockType = state.schema.nodes.codeBlock + if (!codeBlockType) { + return false + } + const node = codeBlockType.create({ language }, text ? state.schema.text(text) : undefined) + const tr = state.tr.replaceSelectionWith(node).scrollIntoView() + const cursor = tr.selection.from + 1 + tr.setSelection(TextSelection.create(tr.doc, cursor, cursor)) + dispatch?.(tr) + return true + }) +} + +export function insertToggle(editor: Editor, variant?: 'heading-1'): void { + const insertAt = editor.state.selection.from + + editor + .chain() + .focus() + .insertContentAt(insertAt, { + type: 'details', + attrs: { + open: true, + ...(variant ? { variant } : {}) + }, + content: [ + { + type: 'detailsSummary' + }, + { + type: 'detailsContent', + content: [{ type: 'paragraph' }] + } + ] + }) + .setTextSelection(insertAt + 1) + .run() +} diff --git a/src/renderer/src/components/editor/rich-markdown-slash-commands.tsx b/src/renderer/src/components/editor/rich-markdown-slash-commands.tsx index 12b633a9763..c241676a94a 100644 --- a/src/renderer/src/components/editor/rich-markdown-slash-commands.tsx +++ b/src/renderer/src/components/editor/rich-markdown-slash-commands.tsx @@ -1,135 +1,15 @@ import type React from 'react' import type { Editor } from '@tiptap/react' -import { TextSelection } from '@tiptap/pm/state' -import type {} from '@tiptap/extension-mathematics' -import { - ChevronRight, - Heading1, - Heading2, - Heading3, - ImageIcon, - List, - ListOrdered, - Quote, - Sigma, - Table2, - Workflow -} from 'lucide-react' -import { translate } from '@/i18n/i18n' +import type { SlashCommand, SlashMenuState } from './rich-markdown-slash-command-catalog' -export type SlashMenuState = { - query: string - from: number - to: number - left: number - top: number -} - -export type SlashCommandId = - | 'text' - | 'toggle-text' - | 'heading-1' - | 'toggle-h1' - | 'heading-2' - | 'heading-3' - | 'task-list' - | 'bullet-list' - | 'ordered-list' - | 'blockquote' - | 'code-block' - | 'divider' - | 'image' - | 'table' - | 'mermaid' - | 'inline-math' - | 'math-block' - | 'emoji' - -export type SlashCommandIcon = - | { kind: 'component'; component: React.ComponentType<{ className?: string }> } - | { kind: 'text'; value: string } - -export type SlashCommandGroup = 'Headings' | 'Basic blocks' | 'Advanced' | 'Media' | 'Others' - -export type SlashCommand = { - id: SlashCommandId - label: string - aliases: string[] - icon: SlashCommandIcon - group: SlashCommandGroup - description: string - run: (editor: Editor) => void -} - -function icon(component: React.ComponentType<{ className?: string }>): SlashCommandIcon { - return { kind: 'component', component } -} - -function textIcon(value: string): SlashCommandIcon { - return { kind: 'text', value } -} - -function insertTextWithSelection( - editor: Editor, - text: string, - selectionStartOffset?: number, - selectionEndOffset = selectionStartOffset -): void { - editor.commands.command(({ state, dispatch }) => { - const from = state.selection.from - const tr = state.tr.insertText(text, from, state.selection.to) - - if (selectionStartOffset !== undefined) { - const selectionFrom = from + selectionStartOffset - const selectionTo = from + (selectionEndOffset ?? selectionStartOffset) - tr.setSelection(TextSelection.create(tr.doc, selectionFrom, selectionTo)) - } - - dispatch?.(tr.scrollIntoView()) - return true - }) -} - -function insertCodeBlock(editor: Editor, language: string, text: string): void { - editor.commands.command(({ state, dispatch }) => { - const codeBlockType = state.schema.nodes.codeBlock - if (!codeBlockType) { - return false - } - const node = codeBlockType.create({ language }, text ? state.schema.text(text) : undefined) - const tr = state.tr.replaceSelectionWith(node).scrollIntoView() - const cursor = tr.selection.from + 1 - tr.setSelection(TextSelection.create(tr.doc, cursor, cursor)) - dispatch?.(tr) - return true - }) -} - -function insertToggle(editor: Editor, variant?: 'heading-1'): void { - const insertAt = editor.state.selection.from - - editor - .chain() - .focus() - .insertContentAt(insertAt, { - type: 'details', - attrs: { - open: true, - ...(variant ? { variant } : {}) - }, - content: [ - { - type: 'detailsSummary' - }, - { - type: 'detailsContent', - content: [{ type: 'paragraph' }] - } - ] - }) - .setTextSelection(insertAt + 1) - .run() -} +export { slashCommands } from './rich-markdown-slash-command-catalog' +export type { + SlashCommand, + SlashCommandGroup, + SlashCommandIcon, + SlashCommandId, + SlashMenuState +} from './rich-markdown-slash-command-catalog' /** * Executes a slash command by first deleting the typed slash text, then @@ -157,215 +37,6 @@ export function runSlashCommand( command.run(editor) } -export const slashCommands: SlashCommand[] = [ - { - id: 'heading-1', - label: translate("auto.components.editor.rich.markdown.slash.commands.e66e7f04c6", "Heading 1"), - aliases: ['h1', 'title'], - icon: icon(Heading1), - group: 'Headings', - description: translate("auto.components.editor.rich.markdown.slash.commands.570611864e", "Large section heading."), - run: (editor) => { - // Use setHeading (not toggleHeading) so the slash command is idempotent — - // invoking "/h1" on an existing H1 should keep it as H1, not revert to paragraph. - editor.chain().focus().setHeading({ level: 1 }).run() - } - }, - { - id: 'toggle-h1', - label: translate("auto.components.editor.rich.markdown.slash.commands.41482b15ce", "Toggle Heading 1"), - aliases: ['toggle-h1', 'toggle heading', 'details heading', 'collapse heading'], - icon: icon(ChevronRight), - group: 'Headings', - description: translate("auto.components.editor.rich.markdown.slash.commands.3294a2c0cc", "Create a collapsible section with a large heading summary."), - run: (editor) => { - insertToggle(editor, 'heading-1') - } - }, - { - id: 'heading-2', - label: translate("auto.components.editor.rich.markdown.slash.commands.c209a116b7", "Heading 2"), - aliases: ['h2'], - icon: icon(Heading2), - group: 'Headings', - description: translate("auto.components.editor.rich.markdown.slash.commands.45cf7ceb3f", "Medium section heading."), - run: (editor) => { - // Use setHeading (not toggleHeading) so the slash command is idempotent — - // invoking "/h2" on an existing H2 should keep it as H2, not revert to paragraph. - editor.chain().focus().setHeading({ level: 2 }).run() - } - }, - { - id: 'heading-3', - label: translate("auto.components.editor.rich.markdown.slash.commands.30566ee962", "Heading 3"), - aliases: ['h3'], - icon: icon(Heading3), - group: 'Headings', - description: translate("auto.components.editor.rich.markdown.slash.commands.4920740259", "Small section heading."), - run: (editor) => { - // Use setHeading (not toggleHeading) so the slash command is idempotent — - // invoking "/h3" on an existing H3 should keep it as H3, not revert to paragraph. - editor.chain().focus().setHeading({ level: 3 }).run() - } - }, - { - id: 'blockquote', - label: translate("auto.components.editor.rich.markdown.slash.commands.c4c775778b", "Quote"), - aliases: ['quote', 'blockquote'], - icon: icon(Quote), - group: 'Basic blocks', - description: translate("auto.components.editor.rich.markdown.slash.commands.6a3def14de", "Insert a blockquote."), - run: (editor) => { - editor.chain().focus().toggleBlockquote().run() - } - }, - { - id: 'ordered-list', - label: translate("auto.components.editor.rich.markdown.slash.commands.ed4cf0ebce", "Numbered List"), - aliases: ['ordered', 'ol', 'numbered'], - icon: icon(ListOrdered), - group: 'Basic blocks', - description: translate("auto.components.editor.rich.markdown.slash.commands.8e00aba296", "Create an ordered list."), - run: (editor) => { - editor.chain().focus().toggleOrderedList().run() - } - }, - { - id: 'bullet-list', - label: translate("auto.components.editor.rich.markdown.slash.commands.56ff3237e7", "Bullet List"), - aliases: ['bullet', 'ul', 'list'], - icon: icon(List), - group: 'Basic blocks', - description: translate("auto.components.editor.rich.markdown.slash.commands.c9b9e826b8", "Create an unordered list."), - run: (editor) => { - editor.chain().focus().toggleBulletList().run() - } - }, - { - id: 'task-list', - label: translate("auto.components.editor.rich.markdown.slash.commands.d0d2cdfbdb", "Check List"), - aliases: ['todo', 'task', 'checkbox'], - icon: icon(List), - group: 'Basic blocks', - description: translate("auto.components.editor.rich.markdown.slash.commands.d766f44867", "Create a checklist."), - run: (editor) => { - editor.chain().focus().toggleTaskList().run() - } - }, - { - id: 'text', - label: translate("auto.components.editor.rich.markdown.slash.commands.58abdb9d41", "Paragraph"), - aliases: ['paragraph', 'plain'], - icon: icon(List), - group: 'Basic blocks', - description: translate("auto.components.editor.rich.markdown.slash.commands.9a7fe896dc", "Start a normal paragraph."), - run: (editor) => { - editor.chain().focus().setParagraph().run() - } - }, - { - id: 'toggle-text', - label: translate("auto.components.editor.rich.markdown.slash.commands.f82c78a2ee", "Toggle Text"), - aliases: ['toggle', 'details', 'collapse', 'toggle-text'], - icon: icon(ChevronRight), - group: 'Basic blocks', - description: translate("auto.components.editor.rich.markdown.slash.commands.972ef9aeea", "Create a collapsible text section."), - run: (editor) => { - insertToggle(editor) - } - }, - { - id: 'code-block', - label: translate("auto.components.editor.rich.markdown.slash.commands.624b50cf25", "Code Block"), - aliases: ['code', 'snippet'], - icon: icon(List), - group: 'Basic blocks', - description: translate("auto.components.editor.rich.markdown.slash.commands.89e327e054", "Insert a fenced code block."), - run: (editor) => { - editor.chain().focus().toggleCodeBlock().run() - } - }, - { - id: 'divider', - label: translate("auto.components.editor.rich.markdown.slash.commands.ae8377cf6b", "Divider"), - aliases: ['divider', 'rule', 'hr'], - icon: icon(List), - group: 'Basic blocks', - description: translate("auto.components.editor.rich.markdown.slash.commands.fae45ef4d3", "Insert a horizontal rule."), - run: (editor) => { - editor.chain().focus().setHorizontalRule().run() - } - }, - { - id: 'table', - label: translate("auto.components.editor.rich.markdown.slash.commands.19ea597868", "Table"), - aliases: ['grid', 'columns', 'rows'], - icon: icon(Table2), - group: 'Advanced', - description: translate("auto.components.editor.rich.markdown.slash.commands.67faab829b", "Insert a 3x3 markdown table."), - run: (editor) => { - editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() - } - }, - { - id: 'mermaid', - label: translate("auto.components.editor.rich.markdown.slash.commands.e516d3f6e3", "Mermaid Diagram"), - aliases: ['diagram', 'flowchart', 'chart', 'graph'], - icon: icon(Workflow), - group: 'Advanced', - description: translate("auto.components.editor.rich.markdown.slash.commands.0ed9a7b38c", "Insert a Mermaid fenced block."), - run: (editor) => { - insertCodeBlock(editor, 'mermaid', 'graph TD\n A[Start] --> B[End]') - } - }, - { - id: 'inline-math', - label: translate("auto.components.editor.rich.markdown.slash.commands.2bf5544faf", "Inline Math"), - aliases: ['math', 'latex', 'equation', 'formula'], - icon: icon(Sigma), - group: 'Advanced', - description: translate("auto.components.editor.rich.markdown.slash.commands.565907cf7a", "Insert inline LaTeX math."), - run: (editor) => { - editor.commands.insertInlineMath({ latex: 'x' }) - } - }, - { - id: 'math-block', - label: translate("auto.components.editor.rich.markdown.slash.commands.6993a38ad1", "Math Block"), - aliases: ['display math', 'latex block', 'equation block'], - icon: icon(Sigma), - group: 'Advanced', - description: translate("auto.components.editor.rich.markdown.slash.commands.ae7d0f3f37", "Insert display LaTeX math."), - run: (editor) => { - editor.commands.insertBlockMath({ latex: 'x' }) - } - }, - { - id: 'image', - label: translate("auto.components.editor.rich.markdown.slash.commands.572be8e524", "Image"), - aliases: ['image', 'img'], - icon: icon(ImageIcon), - group: 'Media', - description: translate("auto.components.editor.rich.markdown.slash.commands.3324eb391a", "Insert an image from your computer."), - // Why: window.prompt() is not supported in Electron's renderer process, - // so image URL input is handled by an inline input bar in RichMarkdownEditor. - run: (editor) => { - editor.chain().focus().run() - } - }, - { - id: 'emoji', - label: translate("auto.components.editor.rich.markdown.slash.commands.8a30cbaeca", "Emoji"), - aliases: ['smile', 'reaction', 'icon'], - icon: textIcon('🙂'), - group: 'Others', - description: translate("auto.components.editor.rich.markdown.slash.commands.07e1b32396", "Insert a plain Unicode emoji."), - run: (editor) => { - insertTextWithSelection(editor, '🙂') - } - } -] - /** * Inspects the editor selection to decide whether the slash-command menu * should be open (and where to position it), or dismissed. diff --git a/src/renderer/src/components/feature-wall/AgentCapabilitiesSetupAction.tsx b/src/renderer/src/components/feature-wall/AgentCapabilitiesSetupAction.tsx index 2e79dc2bb2c..9787a501e90 100644 --- a/src/renderer/src/components/feature-wall/AgentCapabilitiesSetupAction.tsx +++ b/src/renderer/src/components/feature-wall/AgentCapabilitiesSetupAction.tsx @@ -142,38 +142,50 @@ type AgentCapabilitySetupRow = { const AGENT_CAPABILITY_SETUP_ROWS: readonly AgentCapabilitySetupRow[] = [ { id: 'orchestration', - title: translate( - 'auto.components.feature.wall.AgentCapabilitiesSetupAction.ac07f8887f', - 'Agent Orchestration' - ), - description: translate( - 'auto.components.feature.wall.AgentCapabilitiesSetupAction.c61c91e642', - 'Let agents coordinate through Orca to keep large, multi-step tasks moving to completion.' - ), + get title() { + return translate( + 'auto.components.feature.wall.AgentCapabilitiesSetupAction.ac07f8887f', + 'Agent Orchestration' + ) + }, + get description() { + return translate( + 'auto.components.feature.wall.AgentCapabilitiesSetupAction.c61c91e642', + 'Let agents coordinate through Orca to keep large, multi-step tasks moving to completion.' + ) + }, icon: }, { id: 'browserUse', - title: translate( - 'auto.components.feature.wall.AgentCapabilitiesSetupAction.e638da007a', - 'Agent Browser Use' - ), - description: translate( - 'auto.components.feature.wall.AgentCapabilitiesSetupAction.5e8fe5a72d', - "Give agents direct access to Orca's browser so they can test pages, capture screenshots, and act on what they see." - ), + get title() { + return translate( + 'auto.components.feature.wall.AgentCapabilitiesSetupAction.e638da007a', + 'Agent Browser Use' + ) + }, + get description() { + return translate( + 'auto.components.feature.wall.AgentCapabilitiesSetupAction.5e8fe5a72d', + "Give agents direct access to Orca's browser so they can test pages, capture screenshots, and act on what they see." + ) + }, icon: }, { id: 'computerUse', - title: translate( - 'auto.components.feature.wall.AgentCapabilitiesSetupAction.362a07517d', - 'Computer Use' - ), - description: translate( - 'auto.components.feature.wall.AgentCapabilitiesSetupAction.1b51644c2d', - 'Let agents control the desktop, moving the cursor, clicking, and typing in any app.' - ), + get title() { + return translate( + 'auto.components.feature.wall.AgentCapabilitiesSetupAction.362a07517d', + 'Computer Use' + ) + }, + get description() { + return translate( + 'auto.components.feature.wall.AgentCapabilitiesSetupAction.1b51644c2d', + 'Let agents control the desktop, moving the cursor, clicking, and typing in any app.' + ) + }, icon: } ] diff --git a/src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx b/src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx index def5e2993cd..3d229ddaa91 100644 --- a/src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx +++ b/src/renderer/src/components/feature-wall/BrowserAnimatedVisual.tsx @@ -127,17 +127,22 @@ const TERM_ENTRIES: readonly { entry: TermEntry; minPhase: Phase }[] = [ { entry: { kind: 'ok', - html: ( - <> - {translate('auto.components.feature.wall.BrowserAnimatedVisual.4fa59ca545', '✓ Updated')}{' '} - + get html() { + return ( + <> {translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.051c97d15a', - '.pp-card[data-card="starter"] .pp-cta' - )} - - - ) + 'auto.components.feature.wall.BrowserAnimatedVisual.4fa59ca545', + '✓ Updated' + )}{' '} + + {translate( + 'auto.components.feature.wall.BrowserAnimatedVisual.051c97d15a', + '.pp-card[data-card="starter"] .pp-cta' + )} + + + ) + } }, minPhase: 'updated' }, @@ -156,14 +161,16 @@ const TERM_ENTRIES: readonly { entry: TermEntry; minPhase: Phase }[] = [ { entry: { kind: 'ok', - html: ( - <> - {translate( - 'auto.components.feature.wall.BrowserAnimatedVisual.eb88125c6f', - '✓ Verified — Try free still works.' - )} - - ) + get html() { + return ( + <> + {translate( + 'auto.components.feature.wall.BrowserAnimatedVisual.eb88125c6f', + '✓ Verified — Try free still works.' + )} + + ) + } }, minPhase: 'verified' } diff --git a/src/renderer/src/components/feature-wall/FeatureTourPreview.tsx b/src/renderer/src/components/feature-wall/FeatureTourPreview.tsx index 2632e530210..8f42e4f40c5 100644 --- a/src/renderer/src/components/feature-wall/FeatureTourPreview.tsx +++ b/src/renderer/src/components/feature-wall/FeatureTourPreview.tsx @@ -7,48 +7,25 @@ import { MailGlyph, WorkingSpinner } from './feature-tour-preview-glyphs' +import { + FEATURE_TOUR_ORCHESTRATION_CHILDREN, + FEATURE_TOUR_PREVIEW_COPY +} from './feature-tour-preview-copy' import { FeatureTourWorkspaceCard } from './FeatureTourWorkspaceCard' import { translate } from '@/i18n/i18n' -type FrameId = 1 | 2 | 3 | 4 - -export type FeatureTourPreviewFrameCopy = { - id: FrameId - title: string - caption: string -} - -export const FEATURE_TOUR_PREVIEW_COPY: readonly FeatureTourPreviewFrameCopy[] = [ - { - id: 1, - title: translate("auto.components.feature.wall.FeatureTourPreview.56a0271428", "Isolated workspaces"), - caption: - 'Ship several things at once. Each workspace keeps its branch, terminal, and agent activity together.' - }, - { - id: 2, - title: translate("auto.components.feature.wall.FeatureTourPreview.e44269e97d", "Agent orchestration"), - caption: 'Hand off a goal and walk away. A coordinator agent fans out and ships parallel PRs.' - }, - { - id: 3, - title: translate("auto.components.feature.wall.FeatureTourPreview.ef737dcee1", "GitHub & Linear tasks"), - caption: - 'Skip the tab-switching. Pick from your GitHub or Linear backlog and start a workspace in one click.' - }, - { - id: 4, - title: translate("auto.components.feature.wall.FeatureTourPreview.1aa8a9a24a", "Splittable terminal"), - caption: - 'Open any workspace to return to its terminal, then split panes for tests, logs, and agents.' - } -] +export { FEATURE_TOUR_PREVIEW_COPY } from './feature-tour-preview-copy' +export type { FeatureTourPreviewFrameCopy } from './feature-tour-preview-copy' function WorkspaceFrame(): JSX.Element { return (
- {translate("auto.components.feature.wall.FeatureTourPreview.56a0271428", "Isolated workspaces")}
+ {translate( + 'auto.components.feature.wall.FeatureTourPreview.56a0271428', + 'Isolated workspaces' + )} +
{/* Why: 3 cards in a row tells the "ship several at once" story by composition; the wide preview aspect (~4.9:1) makes a vertical stack read as wasted space. The grid auto-sizes (no flex-1) so the cards @@ -57,7 +34,10 @@ function WorkspaceFrame(): JSX.Element {
@@ -78,22 +64,6 @@ function WorkspaceFrame(): JSX.Element { ) } -type OrchChildAgent = 'claude' | 'codex' | 'opencode-go' - -const ORCH_CHILDREN: readonly { - key: 'top' | 'mid' | 'bot' - position: string - label: string - agent: OrchChildAgent -}[] = [ - // Why: card vertical centers anchor to 18% / 50% / 82% — the same Y - // endpoints the dashed SVG paths terminate at — so the connectors land on - // each card's center regardless of card height. - { key: 'top', position: 'top-[18%] -translate-y-1/2', label: translate("auto.components.feature.wall.FeatureTourPreview.b1f17bcc74", "PR 1/3"), agent: 'claude' }, - { key: 'mid', position: 'top-1/2 -translate-y-1/2', label: translate("auto.components.feature.wall.FeatureTourPreview.cfdfd4d6b4", "PR 2/3"), agent: 'codex' }, - { key: 'bot', position: 'top-[82%] -translate-y-1/2', label: translate("auto.components.feature.wall.FeatureTourPreview.ec4a73f5e6", "PR 3/3"), agent: 'opencode-go' } -] - function OrchestrationFrame(): JSX.Element { // Why: a horizontal fan (root → 3 children L→R) reads naturally as // "fans out and ships parallel PRs" at the wide aspect; the previous @@ -104,7 +74,11 @@ function OrchestrationFrame(): JSX.Element { return (
- {translate("auto.components.feature.wall.FeatureTourPreview.e44269e97d", "Agent orchestration")}
+ {translate( + 'auto.components.feature.wall.FeatureTourPreview.e44269e97d', + 'Agent orchestration' + )} +
{/* Why: viewBox is percent-units (100×100, preserveAspectRatio="none") so endpoints anchor to the same percentage anchors as the cards @@ -158,20 +132,28 @@ function OrchestrationFrame(): JSX.Element {
- {translate("auto.components.feature.wall.FeatureTourPreview.cebc7769cd", "redesign auth flow")} + {translate( + 'auto.components.feature.wall.FeatureTourPreview.cebc7769cd', + 'redesign auth flow' + )} +
- {translate("auto.components.feature.wall.FeatureTourPreview.5171768676", "orchestrating 3 agents")} + {translate( + 'auto.components.feature.wall.FeatureTourPreview.5171768676', + 'orchestrating 3 agents' + )} +
{/* Why: children mirror the parent's WorkspaceCard composition so the fan reads as "coordinator workspace dispatches to 3 child workspaces, each running its own agent." */} - {ORCH_CHILDREN.map(({ key, position, label, agent }) => ( + {FEATURE_TOUR_ORCHESTRATION_CHILDREN.map(({ key, position, label, agent }) => (
- {agent === "claude" ? ( + {agent === 'claude' ? ( - ) : agent === "codex" ? ( + ) : agent === 'codex' ? ( ) : ( @@ -200,7 +182,7 @@ function OrchestrationFrame(): JSX.Element {
))} - {ORCH_CHILDREN.map(({ key }) => ( + {FEATURE_TOUR_ORCHESTRATION_CHILDREN.map(({ key }) => (
@@ -218,12 +200,17 @@ function TasksFrame(): JSX.Element { return (
- {translate("auto.components.feature.wall.FeatureTourPreview.bee6b4088d", "GitHub & Linear tasks")}
+ {translate( + 'auto.components.feature.wall.FeatureTourPreview.bee6b4088d', + 'GitHub & Linear tasks' + )} +
- {translate("auto.components.feature.wall.FeatureTourPreview.0688842445", "GH #1799")} + {translate('auto.components.feature.wall.FeatureTourPreview.0688842445', 'GH #1799')} + {/* Why: surrounding rows show only the issue number + a skeleton so the user's eye is drawn to the row that has real text — the one the cursor clicks on. */} @@ -231,12 +218,18 @@ function TasksFrame(): JSX.Element {
- {translate("auto.components.feature.wall.FeatureTourPreview.fc0cc0b267", "GH #1842")} + {translate('auto.components.feature.wall.FeatureTourPreview.fc0cc0b267', 'GH #1842')} + - {translate("auto.components.feature.wall.FeatureTourPreview.c1f28c03b2", "Worktree picker truncates")} + {translate( + 'auto.components.feature.wall.FeatureTourPreview.c1f28c03b2', + 'Worktree picker truncates' + )} + - {translate("auto.components.feature.wall.FeatureTourPreview.40bbd92ef4", "Start")}
- {translate("auto.components.feature.wall.FeatureTourPreview.d54aefe09e", "LIN-329")} + {translate('auto.components.feature.wall.FeatureTourPreview.d54aefe09e', 'LIN-329')} +
@@ -271,7 +265,11 @@ function TasksFrame(): JSX.Element {
- {translate("auto.components.feature.wall.FeatureTourPreview.3822d8d14b", "fix/worktree-picker-truncates")} + {translate( + 'auto.components.feature.wall.FeatureTourPreview.3822d8d14b', + 'fix/worktree-picker-truncates' + )} + @@ -281,7 +279,12 @@ function TasksFrame(): JSX.Element {
-
{translate("auto.components.feature.wall.FeatureTourPreview.2a7cfc82c8", "Linked to GH #1842")}
+
+ {translate( + 'auto.components.feature.wall.FeatureTourPreview.2a7cfc82c8', + 'Linked to GH #1842' + )} +
@@ -292,48 +295,73 @@ function TerminalFrame(): JSX.Element { return (
- {translate("auto.components.feature.wall.FeatureTourPreview.1aa8a9a24a", "Splittable terminal")}
+ {translate( + 'auto.components.feature.wall.FeatureTourPreview.1aa8a9a24a', + 'Splittable terminal' + )} +
- {translate("auto.components.feature.wall.FeatureTourPreview.04d54d50ec", "orca · zsh")} + {translate('auto.components.feature.wall.FeatureTourPreview.04d54d50ec', 'orca · zsh')} +
$ - {translate("auto.components.feature.wall.FeatureTourPreview.6218a9014d", "pnpm playwright test")} + {translate( + 'auto.components.feature.wall.FeatureTourPreview.6218a9014d', + 'pnpm playwright test' + )} +
- {translate("auto.components.feature.wall.FeatureTourPreview.8279e9d95b", "Running 12 tests")}
+ {translate( + 'auto.components.feature.wall.FeatureTourPreview.8279e9d95b', + 'Running 12 tests' + )} +
- {translate("auto.components.feature.wall.FeatureTourPreview.24fedd5a52", "login.spec.ts")} + + {translate( + 'auto.components.feature.wall.FeatureTourPreview.24fedd5a52', + 'login.spec.ts' + )} +
- {translate("auto.components.feature.wall.FeatureTourPreview.6ed43cb0e0", "dashboard.spec.ts")} + + {translate( + 'auto.components.feature.wall.FeatureTourPreview.6ed43cb0e0', + 'dashboard.spec.ts' + )} +
$ - {translate("auto.components.feature.wall.FeatureTourPreview.771d8881c2", "claude")} + + {translate('auto.components.feature.wall.FeatureTourPreview.771d8881c2', 'claude')} +
- {translate("auto.components.feature.wall.FeatureTourPreview.952d3ddd9a", "session started")} + + {translate( + 'auto.components.feature.wall.FeatureTourPreview.952d3ddd9a', + 'session started' + )} +
- {translate("auto.components.feature.wall.FeatureTourPreview.1170621527", ">")} - {translate("auto.components.feature.wall.FeatureTourPreview.ef8b164dd1", "review src/auth")} + + {translate('auto.components.feature.wall.FeatureTourPreview.1170621527', '>')} + + + {translate( + 'auto.components.feature.wall.FeatureTourPreview.ef8b164dd1', + 'review src/auth' + )} +
- {translate("auto.components.feature.wall.FeatureTourPreview.304ad0dfc1", "Thinking...")} + + {translate( + 'auto.components.feature.wall.FeatureTourPreview.304ad0dfc1', + 'Thinking...' + )} +
diff --git a/src/renderer/src/components/feature-wall/ReviewPRViewAnimatedVisual.tsx b/src/renderer/src/components/feature-wall/ReviewPRViewAnimatedVisual.tsx index 590d0cd2475..be3148a0e7c 100644 --- a/src/renderer/src/components/feature-wall/ReviewPRViewAnimatedVisual.tsx +++ b/src/renderer/src/components/feature-wall/ReviewPRViewAnimatedVisual.tsx @@ -1,9 +1,10 @@ -import { useEffect, useRef } from 'react' +import { useRef } from 'react' import type { ComponentType, JSX, ReactNode } from 'react' import { Files, GitBranch, ListChecks, MessageSquare, Search } from 'lucide-react' import { useShortcutLabel } from '@/hooks/useShortcutLabel' import { ReviewPRViewVisualStyles } from './review-animated-visual-pr-view-styles' import { CheckTinyIcon, ChevDownIcon, CursorIcon } from './review-animated-visual-shared' +import { useReviewPrViewAnimation } from './review-pr-view-animation' import { translate } from '@/i18n/i18n' type SidebarTabId = 'explorer' | 'search' | 'source-control' | 'checks' @@ -13,10 +14,46 @@ const SIDEBAR_TABS: readonly { icon: ComponentType<{ className?: string; size?: number }> label: string }[] = [ - { id: 'explorer', icon: Files, label: translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.6e3f5223c5", "Explorer") }, - { id: 'search', icon: Search, label: translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.8e715588e4", "Search") }, - { id: 'source-control', icon: GitBranch, label: translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.d7f80060ca", "Source Control") }, - { id: 'checks', icon: ListChecks, label: translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.ab2901bce6", "Checks") } + { + id: 'explorer', + icon: Files, + get label() { + return translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.6e3f5223c5', + 'Explorer' + ) + } + }, + { + id: 'search', + icon: Search, + get label() { + return translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.8e715588e4', + 'Search' + ) + } + }, + { + id: 'source-control', + icon: GitBranch, + get label() { + return translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.d7f80060ca', + 'Source Control' + ) + } + }, + { + id: 'checks', + icon: ListChecks, + get label() { + return translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.ab2901bce6', + 'Checks' + ) + } + } ] function SidebarTabs(props: { active: SidebarTabId; interactiveChecks?: boolean }): JSX.Element { @@ -85,20 +122,6 @@ function CommentCard(props: { index: number; path: string; children: ReactNode } ) } -function moveCursor( - root: HTMLElement, - cursor: HTMLElement, - anchor: HTMLElement, - ox = 0, - oy = 0 -): void { - const rootRect = root.getBoundingClientRect() - const anchorRect = anchor.getBoundingClientRect() - cursor.style.transform = `translate(${anchorRect.left - rootRect.left + ox}px, ${ - anchorRect.top - rootRect.top + oy - }px)` -} - // Why: the Review PR visual follows the approved HTML mock beat-for-beat. The // real app keeps Explorer / Checks in one right-sidebar surface, so the // animation selects Checks before the PR status content appears. @@ -106,205 +129,7 @@ export function ReviewPRViewAnimatedVisual(props: { reducedMotion: boolean }): J const { reducedMotion } = props const rootRef = useRef(null) - useEffect(() => { - const root = rootRef.current - if (!root) { - return - } - - const sidebarPeek = root.querySelector('[data-checks-sidebar-peek]') - const prCard = root.querySelector('[data-pr-view-card]') - const cursor = root.querySelector('[data-cursor]') - const explorerTab = root.querySelector('[data-explorer-tab]') - const checksTab = root.querySelector('[data-checks-tab]') - const checksTooltip = root.querySelector('[data-checks-tooltip]') - const checksBlock = root.querySelector('[data-checks-block]') - const commentsBlock = root.querySelector('[data-comments-block]') - const comments = Array.from(root.querySelectorAll('[data-comment-card]')) - const commentsCount = root.querySelector('[data-comments-count]') - const checkSummary = root.querySelector('[data-check-summary]') - const checkSummaryLabel = root.querySelector('[data-check-summary-label]') - const checkSummaryMeta = root.querySelector('[data-check-summary-meta]') - const verifyRow = root.querySelector('[data-check-row="verify"]') - const verifyState = root.querySelector('[data-check-verify-state]') - const mergeBtn = root.querySelector('[data-merge-btn]') - if ( - !sidebarPeek || - !prCard || - !cursor || - !explorerTab || - !checksTab || - !checksTooltip || - !checksBlock || - !commentsBlock || - !commentsCount || - !checkSummary || - !checkSummaryLabel || - !checkSummaryMeta || - !verifyRow || - !verifyState || - !mergeBtn - ) { - return - } - - const rootEl: HTMLDivElement = root - const sidebarPeekEl: HTMLDivElement = sidebarPeek - const prCardEl: HTMLDivElement = prCard - const cursorEl: HTMLDivElement = cursor - const explorerTabEl: HTMLSpanElement = explorerTab - const checksTabEl: HTMLSpanElement = checksTab - const checksTooltipEl: HTMLSpanElement = checksTooltip - const checksBlockEl: HTMLDivElement = checksBlock - const commentsBlockEl: HTMLDivElement = commentsBlock - const commentsCountEl: HTMLSpanElement = commentsCount - const checkSummaryEl: HTMLDivElement = checkSummary - const checkSummaryLabelEl: HTMLSpanElement = checkSummaryLabel - const checkSummaryMetaEl: HTMLSpanElement = checkSummaryMeta - const verifyRowEl: HTMLDivElement = verifyRow - const verifyStateEl: HTMLSpanElement = verifyState - const mergeBtnEl: HTMLButtonElement = mergeBtn - - let cancelled = false - const timers: number[] = [] - const wait = (ms: number): Promise => - new Promise((resolve) => { - const id = window.setTimeout(() => resolve(), ms) - timers.push(id) - }) - - function resetState(): void { - sidebarPeekEl.classList.add('is-visible') - sidebarPeekEl.classList.remove('is-hiding') - prCardEl.classList.remove('is-visible') - explorerTabEl.classList.add('is-active') - checksTabEl.classList.remove('is-active', 'is-hovered') - checksTooltipEl.classList.remove('is-visible') - cursorEl.classList.remove('is-visible', 'is-clicking') - cursorEl.style.transition = 'none' - cursorEl.style.transform = 'translate(-30px, 220px)' - void cursorEl.offsetWidth - cursorEl.style.transition = '' - checksBlockEl.classList.remove('is-visible') - commentsBlockEl.classList.remove('is-visible') - comments.forEach((el) => el.classList.remove('is-visible')) - commentsCountEl.textContent = '0' - checkSummaryEl.classList.remove('is-done') - checkSummaryLabelEl.textContent = '1 pending' - checkSummaryMetaEl.textContent = 'verify' - verifyRowEl.classList.remove('is-done') - verifyStateEl.textContent = 'Running' - mergeBtnEl.classList.remove('is-ready') - } - - function showFinalState(): void { - resetState() - sidebarPeekEl.classList.add('is-hiding') - prCardEl.classList.add('is-visible') - checksBlockEl.classList.add('is-visible') - commentsBlockEl.classList.add('is-visible') - comments.forEach((el) => el.classList.add('is-visible')) - commentsCountEl.textContent = String(comments.length) - checkSummaryEl.classList.add('is-done') - checkSummaryLabelEl.textContent = 'Checks passed' - checkSummaryMetaEl.textContent = '3 checks' - verifyRowEl.classList.add('is-done') - verifyStateEl.textContent = 'Passed' - mergeBtnEl.classList.add('is-ready') - cursorEl.classList.remove('is-visible') - } - - if (reducedMotion) { - showFinalState() - return - } - - async function loop(): Promise { - while (!cancelled) { - resetState() - await wait(420) - if (cancelled) { - return - } - - cursorEl.classList.add('is-visible') - moveCursor(rootEl, cursorEl, checksTabEl, 5, 6) - checksTabEl.classList.add('is-hovered') - await wait(260) - if (cancelled) { - return - } - checksTooltipEl.classList.add('is-visible') - await wait(1300) - if (cancelled) { - return - } - - cursorEl.classList.add('is-clicking') - await wait(220) - if (cancelled) { - return - } - cursorEl.classList.remove('is-clicking') - checksTooltipEl.classList.remove('is-visible') - checksTabEl.classList.remove('is-hovered') - explorerTabEl.classList.remove('is-active') - checksTabEl.classList.add('is-active') - await wait(420) - if (cancelled) { - return - } - - sidebarPeekEl.classList.add('is-hiding') - prCardEl.classList.add('is-visible') - cursorEl.classList.remove('is-visible') - await wait(560) - if (cancelled) { - return - } - - checksBlockEl.classList.add('is-visible') - await wait(1050) - if (cancelled) { - return - } - - verifyRowEl.classList.add('is-done') - verifyStateEl.textContent = 'Passed' - checkSummaryEl.classList.add('is-done') - checkSummaryLabelEl.textContent = 'Checks passed' - checkSummaryMetaEl.textContent = '3 checks' - mergeBtnEl.classList.add('is-ready') - - await wait(560) - if (cancelled) { - return - } - commentsBlockEl.classList.add('is-visible') - await wait(260) - if (cancelled) { - return - } - - for (let i = 0; i < comments.length; i++) { - comments[i]?.classList.add('is-visible') - commentsCountEl.textContent = String(i + 1) - await wait(520) - if (cancelled) { - return - } - } - - await wait(2900) - } - } - - void loop() - return () => { - cancelled = true - timers.forEach((timer) => window.clearTimeout(timer)) - } - }, [reducedMotion]) + useReviewPrViewAnimation(rootRef, reducedMotion) return (
@@ -312,7 +137,12 @@ export function ReviewPRViewAnimatedVisual(props: { reducedMotion: boolean }): J
-
{translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.6e3f5223c5", "Explorer")}
+
+ {translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.6e3f5223c5', + 'Explorer' + )} +
@@ -327,38 +157,89 @@ export function ReviewPRViewAnimatedVisual(props: { reducedMotion: boolean }): J
#2351 - {translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.dfe313e0c9", "OPEN")} + + {translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.dfe313e0c9', + 'OPEN' + )} + +
+
+ {translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.0aab7ab84a', + 'Add local diagnostics error tracking' + )}
-
{translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.0aab7ab84a", "Add local diagnostics error tracking")}
- {translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.9a097cae12", "1 pending")} + {translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.9a097cae12', + '1 pending' + )} + - {translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.d340c052fb", "verify")} + {translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.d340c052fb', + 'verify' + )} +
- {translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.d340c052fb", "verify")} + + {translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.d340c052fb', + 'verify' + )} + - {translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.8ed213397c", "Running")} + {translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.8ed213397c', + 'Running' + )} +
- {translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.2ef0b97954", "typecheck")} - {translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.ca36f7b27c", "Passed")} + + {translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.2ef0b97954', + 'typecheck' + )} + + + {translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.ca36f7b27c', + 'Passed' + )} +
- {translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.25f6838e43", "lint")} - {translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.ca36f7b27c", "Passed")} + + {translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.25f6838e43', + 'lint' + )} + + + {translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.ca36f7b27c', + 'Passed' + )} +
@@ -366,15 +247,43 @@ export function ReviewPRViewAnimatedVisual(props: { reducedMotion: boolean }): J
- {translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.7a8b896e11", "Comments")} + + {translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.7a8b896e11', + 'Comments' + )} + - 0 {translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.fb1a856b6d", "open")} + 0{' '} + {translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.fb1a856b6d', + 'open' + )} +
- {translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.71828fba75", "Can we include the failing command in the diagnostic payload?")} + {translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.71828fba75', + 'Can we include the failing command in the diagnostic payload?' + )} + - {translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.6f4c2d7cb7", "Add a coverage case for")}{translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.c2062da7ec", "stderr")} {translate("auto.components.feature.wall.ReviewPRViewAnimatedVisual.7c2808ecff", "truncation before merge.")} + {translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.6f4c2d7cb7', + 'Add a coverage case for' + )} + + {translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.c2062da7ec', + 'stderr' + )} + {' '} + {translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.7c2808ecff', + 'truncation before merge.' + )} +
diff --git a/src/renderer/src/components/feature-wall/TasksAnimatedVisual.tsx b/src/renderer/src/components/feature-wall/TasksAnimatedVisual.tsx index 73d711449be..af04080f4de 100644 --- a/src/renderer/src/components/feature-wall/TasksAnimatedVisual.tsx +++ b/src/renderer/src/components/feature-wall/TasksAnimatedVisual.tsx @@ -15,10 +15,12 @@ type Issue = { const ISSUES: readonly Issue[] = [ { number: 1842, - title: translate( - 'auto.components.feature.wall.TasksAnimatedVisual.b13375617e', - 'Worktree picker truncates names' - ) + get title() { + return translate( + 'auto.components.feature.wall.TasksAnimatedVisual.b13375617e', + 'Worktree picker truncates names' + ) + } } ] diff --git a/src/renderer/src/components/feature-wall/feature-tour-preview-copy.ts b/src/renderer/src/components/feature-wall/feature-tour-preview-copy.ts new file mode 100644 index 00000000000..1aa0dc8abbc --- /dev/null +++ b/src/renderer/src/components/feature-wall/feature-tour-preview-copy.ts @@ -0,0 +1,109 @@ +import { translate } from '@/i18n/i18n' + +type FrameId = 1 | 2 | 3 | 4 + +export type FeatureTourPreviewFrameCopy = { + id: FrameId + title: string + caption: string +} + +export const FEATURE_TOUR_PREVIEW_COPY: readonly FeatureTourPreviewFrameCopy[] = [ + { + id: 1, + get title() { + return translate( + 'auto.components.feature.wall.FeatureTourPreview.56a0271428', + 'Isolated workspaces' + ) + }, + get caption() { + return translate( + 'auto.components.feature.wall.FeatureTourPreview.47f16ecf34', + 'Ship several things at once. Each workspace keeps its branch, terminal, and agent activity together.' + ) + } + }, + { + id: 2, + get title() { + return translate( + 'auto.components.feature.wall.FeatureTourPreview.e44269e97d', + 'Agent orchestration' + ) + }, + get caption() { + return translate( + 'auto.components.feature.wall.FeatureTourPreview.70aa182266', + 'Hand off a goal and walk away. A coordinator agent fans out and ships parallel PRs.' + ) + } + }, + { + id: 3, + get title() { + return translate( + 'auto.components.feature.wall.FeatureTourPreview.ef737dcee1', + 'GitHub & Linear tasks' + ) + }, + get caption() { + return translate( + 'auto.components.feature.wall.FeatureTourPreview.f10c14dd9d', + 'Skip the tab-switching. Pick from your GitHub or Linear backlog and start a workspace in one click.' + ) + } + }, + { + id: 4, + get title() { + return translate( + 'auto.components.feature.wall.FeatureTourPreview.1aa8a9a24a', + 'Splittable terminal' + ) + }, + get caption() { + return translate( + 'auto.components.feature.wall.FeatureTourPreview.5d6ee181b6', + 'Open any workspace to return to its terminal, then split panes for tests, logs, and agents.' + ) + } + } +] + +export type FeatureTourOrchestrationChildAgent = 'claude' | 'codex' | 'opencode-go' + +export const FEATURE_TOUR_ORCHESTRATION_CHILDREN: readonly { + key: 'top' | 'mid' | 'bot' + position: string + label: string + agent: FeatureTourOrchestrationChildAgent +}[] = [ + // Why: card vertical centers anchor to 18% / 50% / 82% — the same Y + // endpoints the dashed SVG paths terminate at — so the connectors land on + // each card's center regardless of card height. + { + key: 'top', + position: 'top-[18%] -translate-y-1/2', + get label() { + return translate('auto.components.feature.wall.FeatureTourPreview.b1f17bcc74', 'PR 1/3') + }, + agent: 'claude' + }, + { + key: 'mid', + position: 'top-1/2 -translate-y-1/2', + get label() { + return translate('auto.components.feature.wall.FeatureTourPreview.cfdfd4d6b4', 'PR 2/3') + }, + agent: 'codex' + }, + { + key: 'bot', + position: 'top-[82%] -translate-y-1/2', + get label() { + return translate('auto.components.feature.wall.FeatureTourPreview.ec4a73f5e6', 'PR 3/3') + }, + agent: 'opencode-go' + } +] diff --git a/src/renderer/src/components/feature-wall/review-pr-view-animation.ts b/src/renderer/src/components/feature-wall/review-pr-view-animation.ts new file mode 100644 index 00000000000..bda794340f9 --- /dev/null +++ b/src/renderer/src/components/feature-wall/review-pr-view-animation.ts @@ -0,0 +1,261 @@ +import { useEffect } from 'react' +import type { RefObject } from 'react' +import { translate } from '@/i18n/i18n' + +function getReviewPrAnimatedStatusCopy(): { + pendingLabel: string + verifyLabel: string + runningLabel: string + checksPassedLabel: string + checksCountLabel: string + passedLabel: string +} { + return { + pendingLabel: translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.9a097cae12', + '1 pending' + ), + verifyLabel: translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.d340c052fb', + 'verify' + ), + runningLabel: translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.8ed213397c', + 'Running' + ), + checksPassedLabel: translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.a6c8b9e32f', + 'Checks passed' + ), + checksCountLabel: translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.f4d5e1a7b2', + '3 checks' + ), + passedLabel: translate( + 'auto.components.feature.wall.ReviewPRViewAnimatedVisual.ca36f7b27c', + 'Passed' + ) + } +} + +function moveCursor( + root: HTMLElement, + cursor: HTMLElement, + anchor: HTMLElement, + ox = 0, + oy = 0 +): void { + const rootRect = root.getBoundingClientRect() + const anchorRect = anchor.getBoundingClientRect() + cursor.style.transform = `translate(${anchorRect.left - rootRect.left + ox}px, ${ + anchorRect.top - rootRect.top + oy + }px)` +} + +export function useReviewPrViewAnimation( + rootRef: RefObject, + reducedMotion: boolean +): void { + useEffect(() => { + const root = rootRef.current + if (!root) { + return + } + + const sidebarPeek = root.querySelector('[data-checks-sidebar-peek]') + const prCard = root.querySelector('[data-pr-view-card]') + const cursor = root.querySelector('[data-cursor]') + const explorerTab = root.querySelector('[data-explorer-tab]') + const checksTab = root.querySelector('[data-checks-tab]') + const checksTooltip = root.querySelector('[data-checks-tooltip]') + const checksBlock = root.querySelector('[data-checks-block]') + const commentsBlock = root.querySelector('[data-comments-block]') + const comments = Array.from(root.querySelectorAll('[data-comment-card]')) + const commentsCount = root.querySelector('[data-comments-count]') + const checkSummary = root.querySelector('[data-check-summary]') + const checkSummaryLabel = root.querySelector('[data-check-summary-label]') + const checkSummaryMeta = root.querySelector('[data-check-summary-meta]') + const verifyRow = root.querySelector('[data-check-row="verify"]') + const verifyState = root.querySelector('[data-check-verify-state]') + const mergeBtn = root.querySelector('[data-merge-btn]') + if ( + !sidebarPeek || + !prCard || + !cursor || + !explorerTab || + !checksTab || + !checksTooltip || + !checksBlock || + !commentsBlock || + !commentsCount || + !checkSummary || + !checkSummaryLabel || + !checkSummaryMeta || + !verifyRow || + !verifyState || + !mergeBtn + ) { + return + } + + const rootEl: HTMLDivElement = root + const sidebarPeekEl: HTMLDivElement = sidebarPeek + const prCardEl: HTMLDivElement = prCard + const cursorEl: HTMLDivElement = cursor + const explorerTabEl: HTMLSpanElement = explorerTab + const checksTabEl: HTMLSpanElement = checksTab + const checksTooltipEl: HTMLSpanElement = checksTooltip + const checksBlockEl: HTMLDivElement = checksBlock + const commentsBlockEl: HTMLDivElement = commentsBlock + const commentsCountEl: HTMLSpanElement = commentsCount + const checkSummaryEl: HTMLDivElement = checkSummary + const checkSummaryLabelEl: HTMLSpanElement = checkSummaryLabel + const checkSummaryMetaEl: HTMLSpanElement = checkSummaryMeta + const verifyRowEl: HTMLDivElement = verifyRow + const verifyStateEl: HTMLSpanElement = verifyState + const mergeBtnEl: HTMLButtonElement = mergeBtn + + let cancelled = false + const timers: number[] = [] + const wait = (ms: number): Promise => + new Promise((resolve) => { + const id = window.setTimeout(() => resolve(), ms) + timers.push(id) + }) + + function resetState(): void { + sidebarPeekEl.classList.add('is-visible') + sidebarPeekEl.classList.remove('is-hiding') + prCardEl.classList.remove('is-visible') + explorerTabEl.classList.add('is-active') + checksTabEl.classList.remove('is-active', 'is-hovered') + checksTooltipEl.classList.remove('is-visible') + cursorEl.classList.remove('is-visible', 'is-clicking') + cursorEl.style.transition = 'none' + cursorEl.style.transform = 'translate(-30px, 220px)' + void cursorEl.offsetWidth + cursorEl.style.transition = '' + checksBlockEl.classList.remove('is-visible') + commentsBlockEl.classList.remove('is-visible') + comments.forEach((el) => el.classList.remove('is-visible')) + commentsCountEl.textContent = '0' + checkSummaryEl.classList.remove('is-done') + const copy = getReviewPrAnimatedStatusCopy() + checkSummaryLabelEl.textContent = copy.pendingLabel + checkSummaryMetaEl.textContent = copy.verifyLabel + verifyRowEl.classList.remove('is-done') + verifyStateEl.textContent = copy.runningLabel + mergeBtnEl.classList.remove('is-ready') + } + + function showFinalState(): void { + resetState() + sidebarPeekEl.classList.add('is-hiding') + prCardEl.classList.add('is-visible') + checksBlockEl.classList.add('is-visible') + commentsBlockEl.classList.add('is-visible') + comments.forEach((el) => el.classList.add('is-visible')) + commentsCountEl.textContent = String(comments.length) + checkSummaryEl.classList.add('is-done') + const copy = getReviewPrAnimatedStatusCopy() + checkSummaryLabelEl.textContent = copy.checksPassedLabel + checkSummaryMetaEl.textContent = copy.checksCountLabel + verifyRowEl.classList.add('is-done') + verifyStateEl.textContent = copy.passedLabel + mergeBtnEl.classList.add('is-ready') + cursorEl.classList.remove('is-visible') + } + + if (reducedMotion) { + showFinalState() + return + } + + async function loop(): Promise { + while (!cancelled) { + resetState() + await wait(420) + if (cancelled) { + return + } + + cursorEl.classList.add('is-visible') + moveCursor(rootEl, cursorEl, checksTabEl, 5, 6) + checksTabEl.classList.add('is-hovered') + await wait(260) + if (cancelled) { + return + } + checksTooltipEl.classList.add('is-visible') + await wait(1300) + if (cancelled) { + return + } + + cursorEl.classList.add('is-clicking') + await wait(220) + if (cancelled) { + return + } + cursorEl.classList.remove('is-clicking') + checksTooltipEl.classList.remove('is-visible') + checksTabEl.classList.remove('is-hovered') + explorerTabEl.classList.remove('is-active') + checksTabEl.classList.add('is-active') + await wait(420) + if (cancelled) { + return + } + + sidebarPeekEl.classList.add('is-hiding') + prCardEl.classList.add('is-visible') + cursorEl.classList.remove('is-visible') + await wait(560) + if (cancelled) { + return + } + + checksBlockEl.classList.add('is-visible') + await wait(1050) + if (cancelled) { + return + } + + verifyRowEl.classList.add('is-done') + const copy = getReviewPrAnimatedStatusCopy() + verifyStateEl.textContent = copy.passedLabel + checkSummaryEl.classList.add('is-done') + checkSummaryLabelEl.textContent = copy.checksPassedLabel + checkSummaryMetaEl.textContent = copy.checksCountLabel + mergeBtnEl.classList.add('is-ready') + + await wait(560) + if (cancelled) { + return + } + commentsBlockEl.classList.add('is-visible') + await wait(260) + if (cancelled) { + return + } + + for (let i = 0; i < comments.length; i++) { + comments[i]?.classList.add('is-visible') + commentsCountEl.textContent = String(i + 1) + await wait(520) + if (cancelled) { + return + } + } + + await wait(2900) + } + } + + void loop() + return () => { + cancelled = true + timers.forEach((timer) => window.clearTimeout(timer)) + } + }, [reducedMotion, rootRef]) +} diff --git a/src/renderer/src/components/github/github-rate-limit-display.tsx b/src/renderer/src/components/github/github-rate-limit-display.tsx index 4321aeeec04..e40fb04c62a 100644 --- a/src/renderer/src/components/github/github-rate-limit-display.tsx +++ b/src/renderer/src/components/github/github-rate-limit-display.tsx @@ -20,27 +20,30 @@ type BucketMeta = { const BUCKETS: BucketMeta[] = [ { key: 'core', - label: translate('auto.components.github.github.rate.limit.display.bb227706a6', 'REST'), - description: translate( - 'auto.components.github.github.rate.limit.display.c392c749a6', - 'REST API' - ) + get label() { + return translate('auto.components.github.github.rate.limit.display.bb227706a6', 'REST') + }, + get description() { + return translate('auto.components.github.github.rate.limit.display.c392c749a6', 'REST API') + } }, { key: 'search', - label: translate('auto.components.github.github.rate.limit.display.c377a4f06a', 'Search'), - description: translate( - 'auto.components.github.github.rate.limit.display.1f2f28a4de', - 'Search API' - ) + get label() { + return translate('auto.components.github.github.rate.limit.display.c377a4f06a', 'Search') + }, + get description() { + return translate('auto.components.github.github.rate.limit.display.1f2f28a4de', 'Search API') + } }, { key: 'graphql', - label: translate('auto.components.github.github.rate.limit.display.1daf0f22a9', 'GraphQL'), - description: translate( - 'auto.components.github.github.rate.limit.display.01f7323e58', - 'GraphQL API' - ) + get label() { + return translate('auto.components.github.github.rate.limit.display.1daf0f22a9', 'GraphQL') + }, + get description() { + return translate('auto.components.github.github.rate.limit.display.01f7323e58', 'GraphQL API') + } } ] diff --git a/src/renderer/src/components/mobile/mobile-platform-copy.ts b/src/renderer/src/components/mobile/mobile-platform-copy.ts index e09bbbc1eee..dec968086b0 100644 --- a/src/renderer/src/components/mobile/mobile-platform-copy.ts +++ b/src/renderer/src/components/mobile/mobile-platform-copy.ts @@ -6,18 +6,22 @@ export const PLATFORM_COPY: Record< { description: string; ctaLabel: string; url: string } > = { ios: { - description: translate( - 'auto.components.mobile.mobile.platform.copy.432db52b73', - 'Scan with your iPhone camera to open the App Store.' - ), + get description() { + return translate( + 'auto.components.mobile.mobile.platform.copy.432db52b73', + 'Scan with your iPhone camera to open the App Store.' + ) + }, ctaLabel: 'Open App Store', url: 'https://apps.apple.com/app/orca-ide/id6766130217' }, android: { - description: translate( - 'auto.components.mobile.mobile.platform.copy.2a532d6fd7', - 'Scan with your Android camera to download the latest APK from GitHub Releases.' - ), + get description() { + return translate( + 'auto.components.mobile.mobile.platform.copy.2a532d6fd7', + 'Scan with your Android camera to download the latest APK from GitHub Releases.' + ) + }, ctaLabel: 'Download APK', url: 'https://github.com/stablyai/orca/releases/download/mobile-v0.0.12/app-release.apk' } diff --git a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx index 8cbd8bd41d2..38ca4ff3348 100644 --- a/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx +++ b/src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx @@ -11,13 +11,11 @@ import { GitBranchPlus, GitMerge, GitPullRequest, - Github, - Gitlab, LoaderCircle, Search, - Sparkles, X } from 'lucide-react' +import { useTranslation } from 'react-i18next' import { useShallow } from 'zustand/react/shallow' import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command' import { Button } from '@/components/ui/button' @@ -63,29 +61,11 @@ import type { } from '../../../../shared/types' import { resolveSmartWorkspaceCommandValue } from './smart-workspace-command-value' import { translate } from '@/i18n/i18n' - -// Why: GitLab MR list filter — Open / Merged / Closed / All — replaces -// GitHub's search-DSL on the GitLab tab per the agreed scope. -type MrStateFilter = 'opened' | 'merged' | 'closed' | 'all' - -const MR_STATE_FILTERS: { id: MrStateFilter; label: string }[] = [ - { - id: 'opened', - label: translate('auto.components.new.workspace.SmartWorkspaceNameField.622864b52a', 'Open') - }, - { - id: 'merged', - label: translate('auto.components.new.workspace.SmartWorkspaceNameField.2319d87718', 'Merged') - }, - { - id: 'closed', - label: translate('auto.components.new.workspace.SmartWorkspaceNameField.6fad211c66', 'Closed') - }, - { - id: 'all', - label: translate('auto.components.new.workspace.SmartWorkspaceNameField.26824f60dd', 'All') - } -] +import { + getMrStateFilters, + getSmartWorkspaceNameModes, + type MrStateFilter +} from './smart-workspace-localized-options' type RepoOption = ReturnType['repos'][number] @@ -120,47 +100,6 @@ export type SmartWorkspaceNameSelection = { const SEARCH_DEBOUNCE_MS = 200 const RESULT_LIMIT = 12 -const MODES: { - id: SmartNameMode - label: string - Icon: React.ComponentType<{ className?: string }> -}[] = [ - { - id: 'smart', - label: translate('auto.components.new.workspace.SmartWorkspaceNameField.b3c60c2b7c', 'Smart'), - Icon: Sparkles - }, - { - id: 'github', - label: translate('auto.components.new.workspace.SmartWorkspaceNameField.0a180280bd', 'GitHub'), - Icon: Github - }, - { - id: 'linear', - label: translate('auto.components.new.workspace.SmartWorkspaceNameField.7a47af0565', 'Linear'), - Icon: ({ className }: { className?: string }) => ( - - - - ) - }, - { - id: 'gitlab', - label: translate('auto.components.new.workspace.SmartWorkspaceNameField.2cfc6be192', 'GitLab'), - Icon: Gitlab - }, - { - id: 'branches', - label: translate('auto.components.new.workspace.SmartWorkspaceNameField.2e4c7c95fe', 'Branch'), - Icon: GitBranch - }, - { - id: 'text', - label: translate('auto.components.new.workspace.SmartWorkspaceNameField.6f07a18604', 'Name'), - Icon: CaseSensitive - } -] - type RowEntry = SmartWorkspaceSourceRow export default function SmartWorkspaceNameField({ @@ -182,6 +121,9 @@ export default function SmartWorkspaceNameField({ textOnly = false, branchesEnabled = true }: SmartWorkspaceNameFieldProps): React.JSX.Element { + // Why: tab/filter labels use the lightweight translate() helper; subscribing + // here makes them refresh even when language changes don't remount the field. + useTranslation() const { addRepo, checkLinearConnection, @@ -257,25 +199,22 @@ export default function SmartWorkspaceNameField({ ) const gitlabAvailable = availableTaskProviders.includes('gitlab') const linearAvailable = availableTaskProviders.includes('linear') - const availableModes = useMemo( - () => - MODES.filter((item) => { - if (textOnly) { - return item.id === 'text' - } - if (item.id === 'gitlab') { - return gitlabAvailable - } - if (item.id === 'linear') { - return linearAvailable - } - if (item.id === 'branches') { - return branchesEnabled - } - return true - }), - [branchesEnabled, gitlabAvailable, linearAvailable, textOnly] - ) + const availableModes = getSmartWorkspaceNameModes().filter((item) => { + if (textOnly) { + return item.id === 'text' + } + if (item.id === 'gitlab') { + return gitlabAvailable + } + if (item.id === 'linear') { + return linearAvailable + } + if (item.id === 'branches') { + return branchesEnabled + } + return true + }) + const mrStateFilters = getMrStateFilters() const selectedSourceFocusKey = selectedSource ? `${selectedSource.kind}:${selectedSource.label}:${selectedSource.url ?? ''}` @@ -1181,7 +1120,7 @@ export default function SmartWorkspaceNameField({ className="flex shrink-0 items-center gap-1 border-b border-border/40 px-2 py-1.5" onMouseDown={(e) => e.preventDefault()} > - {MR_STATE_FILTERS.map(({ id, label }) => ( + {mrStateFilters.map(({ id, label }) => (
) : null}
- +

- {translate("auto.components.settings.TerminalWindowSection.1d1920dc8a", "Hide the mouse cursor when typing in the terminal.")}

+ {translate( + 'auto.components.settings.TerminalWindowSection.1d1920dc8a', + 'Hide the mouse cursor when typing in the terminal.' + )} +

+ {translate( + 'auto.components.settings.TerminalWindowSection.63f8d9336e', + 'Color Overrides' + )} +
updateSettings({ terminalColorOverrides: undefined })} > - {translate("auto.components.settings.TerminalWindowSection.03c855d15f", "Reset all color overrides")} + {translate( + 'auto.components.settings.TerminalWindowSection.03c855d15f', + 'Reset all color overrides' + )} +
diff --git a/src/renderer/src/components/settings/mobile-auto-restore-options.ts b/src/renderer/src/components/settings/mobile-auto-restore-options.ts index 4c4c70c3962..f0659d70835 100644 --- a/src/renderer/src/components/settings/mobile-auto-restore-options.ts +++ b/src/renderer/src/components/settings/mobile-auto-restore-options.ts @@ -3,25 +3,33 @@ import { translate } from '@/i18n/i18n' export const AUTO_RESTORE_FIT_OPTIONS: { value: string; label: string; ms: number | null }[] = [ { value: 'indefinite', - label: translate( - 'auto.components.settings.MobilePane.aa1263e881', - 'Keep at phone size (default)' - ), + get label() { + return translate( + 'auto.components.settings.MobilePane.aa1263e881', + 'Keep at phone size (default)' + ) + }, ms: null }, { value: '60s', - label: translate('auto.components.settings.MobilePane.c474aa09d8', 'After 1 minute'), + get label() { + return translate('auto.components.settings.MobilePane.c474aa09d8', 'After 1 minute') + }, ms: 60_000 }, { value: '5m', - label: translate('auto.components.settings.MobilePane.d4ba07d914', 'After 5 minutes'), + get label() { + return translate('auto.components.settings.MobilePane.d4ba07d914', 'After 5 minutes') + }, ms: 5 * 60_000 }, { value: '30m', - label: translate('auto.components.settings.MobilePane.ff865419dc', 'After 30 minutes'), + get label() { + return translate('auto.components.settings.MobilePane.ff865419dc', 'After 30 minutes') + }, ms: 30 * 60_000 } ] diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx index 5a02d8724f7..92687be2efd 100644 --- a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx +++ b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx @@ -29,53 +29,195 @@ type SidebarWorkspaceOptionsMenuProps = { } const GROUP_BY_OPTIONS = [ - { id: 'none', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.c2c7a45cda", "None") }, - { id: 'workspace-status', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.e029a2d775", "Status") }, - { id: 'pr-status', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.0f9b959b31", "PR") }, - { id: 'repo', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.2170d553cf", "Project") } + { + id: 'none', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.c2c7a45cda', 'None') + } + }, + { + id: 'workspace-status', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.e029a2d775', 'Status') + } + }, + { + id: 'pr-status', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.0f9b959b31', 'PR') + } + }, + { + id: 'repo', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.2170d553cf', 'Project') + } + } ] as const const CARD_LAYOUT_OPTIONS = [ - { id: 'detailed', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.cc17bd443b", "Detailed") }, - { id: 'compact', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.25105b28cb", "Compact") } + { + id: 'detailed', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.cc17bd443b', 'Detailed') + } + }, + { + id: 'compact', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.25105b28cb', 'Compact') + } + } ] as const const PROPERTY_OPTIONS: { id: WorktreeCardProperty; label: string }[] = [ - { id: 'issue', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.91dfc653e8", "GitHub ticket") }, - { id: 'linear-issue', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.ca4d3c522e", "Linear issue") }, - { id: 'pr', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.b8dcc6f321", "PR/MR link") }, - { id: 'comment', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.26c71e536c", "Notes") }, - { id: 'ports', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.b64d8bcca0", "Ports") }, + { + id: 'issue', + get label() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.91dfc653e8', + 'GitHub ticket' + ) + } + }, + { + id: 'linear-issue', + get label() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.ca4d3c522e', + 'Linear issue' + ) + } + }, + { + id: 'pr', + get label() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.b8dcc6f321', + 'PR/MR link' + ) + } + }, + { + id: 'comment', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.26c71e536c', 'Notes') + } + }, + { + id: 'ports', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.b64d8bcca0', 'Ports') + } + }, // Why: toggles the inline "Agent activity" list rendered below each // workspace card body (see WorktreeCard -> WorktreeCardAgents). Off hides // the list; there is no alternate surface. - { id: 'inline-agents', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.d7084e8bc8", "Agent activity") } + { + id: 'inline-agents', + get label() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.d7084e8bc8', + 'Agent activity' + ) + } + } ] const AGENT_ACTIVITY_DISPLAY_OPTIONS: { id: AgentActivityDisplayMode; label: string }[] = [ - { id: 'compact', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.25105b28cb", "Compact") }, - { id: 'full', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.2a81e07366", "Full list") } + { + id: 'compact', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.25105b28cb', 'Compact') + } + }, + { + id: 'full', + get label() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.2a81e07366', + 'Full list' + ) + } + } ] const SORT_OPTIONS = [ - { id: 'name', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.3728165cdd", "Name"), description: null }, + { + id: 'name', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.3728165cdd', 'Name') + }, + description: null + }, { id: 'smart', - label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.503462f2b4", "Agent Activity"), - description: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.b759bb87ee", "Agents that need attention, then most recent activity.") + get label() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.503462f2b4', + 'Agent Activity' + ) + }, + get description() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.b759bb87ee', + 'Agents that need attention, then most recent activity.' + ) + } + }, + { + id: 'recent', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.b451c8b162', 'Recent') + }, + description: null + }, + { + id: 'repo', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.2170d553cf', 'Project') + }, + description: null }, - { id: 'recent', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.b451c8b162", "Recent"), description: null }, - { id: 'repo', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.2170d553cf", "Project"), description: null }, { id: 'manual', - label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.7b316bdd51", "Manual"), - description: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.7153d07485", "Drag workspaces to arrange them within each group.") + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.7b316bdd51', 'Manual') + }, + get description() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.7153d07485', + 'Drag workspaces to arrange them within each group.' + ) + } } ] as const const PROJECT_ORDER_OPTIONS = [ - { id: 'manual', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.7b316bdd51", "Manual"), description: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.6664282a7b", "Drag projects to arrange them") }, - { id: 'recent', label: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.b451c8b162", "Recent"), description: translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.af9249c505", "Most recent workspace activity") } + { + id: 'manual', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.7b316bdd51', 'Manual') + }, + get description() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.6664282a7b', + 'Drag projects to arrange them' + ) + } + }, + { + id: 'recent', + get label() { + return translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.b451c8b162', 'Recent') + }, + get description() { + return translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.af9249c505', + 'Most recent workspace activity' + ) + } + } ] as const const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsMenu({ @@ -148,8 +290,15 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM className="relative text-muted-foreground" aria-label={ hasAnyFilter - ? translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.bc96dbd041", "Workspace options ({{value0}} active)", { value0: activeFilterLabel }) - : translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.9919ae1082", "Workspace options") + ? translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.bc96dbd041', + 'Workspace options ({{value0}} active)', + { value0: activeFilterLabel } + ) + : translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.9919ae1082', + 'Workspace options' + ) } data-workspace-board-preserve-open={preserveWorkspaceBoardOpen ? '' : undefined} > @@ -168,7 +317,16 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM - {hasAnyFilter ? translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.bc96dbd041", "Workspace options ({{value0}})", { value0: activeFilterLabel }) : translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.9919ae1082", "Workspace options")} + {hasAnyFilter + ? translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.bc96dbd041', + 'Workspace options ({{value0}})', + { value0: activeFilterLabel } + ) + : translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.9919ae1082', + 'Workspace options' + )} - {translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.dc0bb670bc", "Group by")} + + {translate('auto.components.sidebar.SidebarWorkspaceOptionsMenu.dc0bb670bc', 'Group by')} +
- {translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.7bada3b1ab", "Sort by")} + + {translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.7bada3b1ab', + 'Sort by' + )} + {sortLabel} @@ -250,11 +415,16 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM {/* Why: project order only has a visible effect when grouping by project; hide it in none/status/PR modes to avoid a dead control. */} - {groupBy === "repo" && ( + {groupBy === 'repo' && ( - {translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.09faabd875", "Project order")} + + {translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.09faabd875', + 'Project order' + )} + {projectOrderLabel} @@ -292,7 +462,12 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM - {translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.320b675c9a", "Card layout")} + + {translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.320b675c9a', + 'Card layout' + )} + {cardLayoutLabel} @@ -326,9 +501,19 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM - {translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.ba87080fb7", "Show properties")} - {cardLayout === "compact" ? ( - {translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.3d4b9c4997", "Hover")} + + {translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.ba87080fb7', + 'Show properties' + )} + + {cardLayout === 'compact' ? ( + + {translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.3d4b9c4997', + 'Hover' + )} + ) : visiblePropertyCount > 0 ? ( {visiblePropertyCount} @@ -352,7 +537,11 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM ))} - {translate("auto.components.sidebar.SidebarWorkspaceOptionsMenu.95c9754653", "Agent activity layout")} + {translate( + 'auto.components.sidebar.SidebarWorkspaceOptionsMenu.95c9754653', + 'Agent activity layout' + )} + diff --git a/src/renderer/src/components/sidebar/SshDisconnectedDialog.tsx b/src/renderer/src/components/sidebar/SshDisconnectedDialog.tsx index 77e745bc6ad..21fa58d026d 100644 --- a/src/renderer/src/components/sidebar/SshDisconnectedDialog.tsx +++ b/src/renderer/src/components/sidebar/SshDisconnectedDialog.tsx @@ -24,14 +24,36 @@ type SshDisconnectedDialogProps = { } const STATUS_MESSAGES: Partial> = { - disconnected: 'This remote repository is not connected.', - reconnecting: 'Reconnecting to the remote host...', - 'reconnection-failed': 'Reconnection to the remote host failed.', - error: translate( - 'auto.components.sidebar.SshDisconnectedDialog.376bed88e5', - 'The connection to the remote host encountered an error.' - ), - 'auth-failed': 'Authentication to the remote host failed.' + get disconnected() { + return translate( + 'auto.components.sidebar.SshDisconnectedDialog.disconnected', + 'This remote repository is not connected.' + ) + }, + get reconnecting() { + return translate( + 'auto.components.sidebar.SshDisconnectedDialog.reconnecting', + 'Reconnecting to the remote host...' + ) + }, + get 'reconnection-failed'() { + return translate( + 'auto.components.sidebar.SshDisconnectedDialog.reconnectionFailed', + 'Reconnection to the remote host failed.' + ) + }, + get error() { + return translate( + 'auto.components.sidebar.SshDisconnectedDialog.376bed88e5', + 'The connection to the remote host encountered an error.' + ) + }, + get 'auth-failed'() { + return translate( + 'auto.components.sidebar.SshDisconnectedDialog.authFailed', + 'Authentication to the remote host failed.' + ) + } } function isReconnectable(status: SshConnectionStatus): boolean { @@ -76,9 +98,21 @@ export function SshDisconnectedDialog({ status === 'connecting' || status === 'deploying-relay' || status === 'reconnecting' + const reconnectingMessage = + STATUS_MESSAGES.reconnecting ?? + translate( + 'auto.components.sidebar.SshDisconnectedDialog.reconnecting', + 'Reconnecting to the remote host...' + ) + const disconnectedMessage = + STATUS_MESSAGES.disconnected ?? + translate( + 'auto.components.sidebar.SshDisconnectedDialog.disconnected', + 'This remote repository is not connected.' + ) const message = isConnecting - ? 'Reconnecting to the remote host...' - : (STATUS_MESSAGES[status] ?? 'This remote repository is not connected.') + ? reconnectingMessage + : (STATUS_MESSAGES[status] ?? disconnectedMessage) const showReconnect = isReconnectable(status) useEffect(() => { diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index 9312f06859a..3cde58e13d8 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -128,22 +128,30 @@ export const PR_GROUP_META: Record< } > = { done: { - label: translate('auto.components.sidebar.worktree.list.groups.5076efc3d2', 'Done'), + get label() { + return translate('auto.components.sidebar.worktree.list.groups.5076efc3d2', 'Done') + }, icon: ConductorDoneIcon, tone: 'text-[#c7a594]' }, 'in-review': { - label: translate('auto.components.sidebar.worktree.list.groups.6798dc7c94', 'In review'), + get label() { + return translate('auto.components.sidebar.worktree.list.groups.6798dc7c94', 'In review') + }, icon: ConductorReviewIcon, tone: 'text-[#16a34a]' }, 'in-progress': { - label: translate('auto.components.sidebar.worktree.list.groups.7c2f009786', 'In progress'), + get label() { + return translate('auto.components.sidebar.worktree.list.groups.7c2f009786', 'In progress') + }, icon: ConductorProgressIcon, tone: 'text-[#d4a300]' }, closed: { - label: translate('auto.components.sidebar.worktree.list.groups.682ed5d551', 'Closed'), + get label() { + return translate('auto.components.sidebar.worktree.list.groups.682ed5d551', 'Closed') + }, icon: CircleX, tone: 'text-zinc-600 dark:text-zinc-300' } @@ -161,7 +169,9 @@ export function getProjectGroupHeaderKey(groupId: string | null): string { export const PINNED_GROUP_KEY = 'pinned' export const PINNED_GROUP_META = { - label: translate('auto.components.sidebar.worktree.list.groups.4aeefc5996', 'Pinned'), + get label() { + return translate('auto.components.sidebar.worktree.list.groups.4aeefc5996', 'Pinned') + }, tone: 'text-foreground', icon: Pin } as const @@ -169,7 +179,9 @@ export const PINNED_GROUP_META = { export const ALL_GROUP_KEY = 'all' export const ALL_GROUP_META = { - label: translate('auto.components.sidebar.worktree.list.groups.0ed04075b8', 'All'), + get label() { + return translate('auto.components.sidebar.worktree.list.groups.0ed04075b8', 'All') + }, tone: 'text-foreground', icon: List } as const diff --git a/src/renderer/src/components/stats/ClaudeUsageDetails.tsx b/src/renderer/src/components/stats/ClaudeUsageDetails.tsx new file mode 100644 index 00000000000..186616f323e --- /dev/null +++ b/src/renderer/src/components/stats/ClaudeUsageDetails.tsx @@ -0,0 +1,172 @@ +import type { + ClaudeUsageBreakdownRow, + ClaudeUsageDailyPoint, + ClaudeUsageSessionRow, + ClaudeUsageSummary +} from '../../../../shared/claude-usage-types' +import { ClaudeUsageDailyChart } from './ClaudeUsageDailyChart' +import { translate } from '@/i18n/i18n' + +type ClaudeUsageDetailsProps = { + daily: ClaudeUsageDailyPoint[] + formatTokens: (value: number) => string + modelBreakdown: ClaudeUsageBreakdownRow[] + projectBreakdown: ClaudeUsageBreakdownRow[] + recentSessions: ClaudeUsageSessionRow[] + summary: ClaudeUsageSummary | null | undefined +} + +function formatSessionTime(timestamp: string): string { + const parsed = new Date(timestamp) + if (Number.isNaN(parsed.getTime())) { + return timestamp + } + return parsed.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit' + }) +} + +export function ClaudeUsageDetails({ + daily, + formatTokens, + modelBreakdown, + projectBreakdown, + recentSessions, + summary +}: ClaudeUsageDetailsProps): React.JSX.Element { + return ( + <> + + +
+ + +
+ +
+
+

+ {translate('auto.components.stats.ClaudeUsagePane.7e76c84153', 'Recent sessions')} +

+

+ {translate('auto.components.stats.ClaudeUsagePane.abfc4a4943', 'Cache reuse rate:')}{' '} + {summary?.cacheReuseRate !== null && summary?.cacheReuseRate !== undefined + ? `${Math.round(summary.cacheReuseRate * 100)}%` + : translate('auto.components.stats.ClaudeUsagePane.7765a4c3e1', 'n/a')} +

+
+
+ + + + + + + + + + + + + + {recentSessions.map((row) => ( + + + + + + + + + + ))} + +
+ {translate('auto.components.stats.ClaudeUsagePane.01476891c7', 'Last active')} + + {translate('auto.components.stats.ClaudeUsagePane.c17bed0416', 'Project')} + + {translate('auto.components.stats.ClaudeUsagePane.1afc25eb06', 'Model')} + + {translate('auto.components.stats.ClaudeUsagePane.0f03975d59', 'Turns')} + + {translate('auto.components.stats.ClaudeUsagePane.faf3444859', 'Input')} + + {translate('auto.components.stats.ClaudeUsagePane.a8b7487ff7', 'Output')} + + {translate('auto.components.stats.ClaudeUsagePane.21ea00bfa8', 'Cache')} +
+ {formatSessionTime(row.lastActiveAt)} + {row.projectLabel} + {row.model ?? + translate('auto.components.stats.ClaudeUsagePane.cfe2282ffa', 'Unknown')} + {row.turns} + {formatTokens(row.inputTokens)} + + {formatTokens(row.outputTokens)} + + {formatTokens(row.cacheReadTokens + row.cacheWriteTokens)} +
+
+
+ + ) +} + +function ClaudeUsageBreakdownSection({ + formatTokens, + label, + rows, + topLabel, + topValue +}: { + formatTokens: (value: number) => string + label: string + rows: ClaudeUsageBreakdownRow[] + topLabel: string + topValue: string | null | undefined +}): React.JSX.Element { + return ( +
+
+

{label}

+

+ {topLabel}{' '} + {topValue ?? translate('auto.components.stats.ClaudeUsagePane.7765a4c3e1', 'n/a')} +

+
+
+ {rows.slice(0, 5).map((row) => ( +
+
+ {row.label} + + {formatTokens(row.inputTokens + row.outputTokens)} + +
+
+ {row.sessions}{' '} + {translate('auto.components.stats.ClaudeUsagePane.02a046792e', 'sessions •')}{' '} + {row.turns} {translate('auto.components.stats.ClaudeUsagePane.32176e1d44', 'turns')} +
+
+ ))} +
+
+ ) +} diff --git a/src/renderer/src/components/stats/ClaudeUsagePane.tsx b/src/renderer/src/components/stats/ClaudeUsagePane.tsx index 0beae4aada1..1f2377cd527 100644 --- a/src/renderer/src/components/stats/ClaudeUsagePane.tsx +++ b/src/renderer/src/components/stats/ClaudeUsagePane.tsx @@ -23,9 +23,8 @@ import { DropdownMenuTrigger } from '../ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' -import { ClaudeUsageDailyChart } from './ClaudeUsageDailyChart' +import { ClaudeUsageDetails } from './ClaudeUsageDetails' import { ClaudeUsageLoadingState } from './ClaudeUsageLoadingState' -import { ClaudeUsageRecentSessionsTable } from './ClaudeUsageRecentSessionsTable' import { ShareUsageButton } from './ShareUsageButton' import { StatCard } from './StatCard' import { formatCost, formatTokens, formatUpdatedAt } from './usage-formatters' @@ -35,18 +34,30 @@ const RANGE_OPTIONS: ClaudeUsageRange[] = ['7d', '30d', '90d', 'all'] const SCOPE_OPTIONS: { value: ClaudeUsageScope; label: string }[] = [ { value: 'orca', - label: translate('auto.components.stats.ClaudeUsagePane.4f8368c272', 'Orca worktrees only') + get label() { + return translate('auto.components.stats.ClaudeUsagePane.4f8368c272', 'Orca worktrees only') + } }, { value: 'all', - label: translate('auto.components.stats.ClaudeUsagePane.5ce4842c2c', 'All local Claude usage') + get label() { + return translate('auto.components.stats.ClaudeUsagePane.5ce4842c2c', 'All local Claude usage') + } } ] const RANGE_LABELS: Record = { - '7d': 'Last 7 days', - '30d': 'Last 30 days', - '90d': 'Last 90 days', - all: 'All time' + get '7d'() { + return translate('auto.components.stats.ClaudeUsagePane.rangeLast7Days', 'Last 7 days') + }, + get '30d'() { + return translate('auto.components.stats.ClaudeUsagePane.rangeLast30Days', 'Last 30 days') + }, + get '90d'() { + return translate('auto.components.stats.ClaudeUsagePane.rangeLast90Days', 'Last 90 days') + }, + get all() { + return translate('auto.components.stats.ClaudeUsagePane.rangeAllTime', 'All time') + } } export function ClaudeUsagePane(): React.JSX.Element { @@ -311,73 +322,14 @@ export function ClaudeUsagePane(): React.JSX.Element { )}

- - -
-
-
-

- {translate('auto.components.stats.ClaudeUsagePane.0f394c24e3', 'By model')} -

-

- {translate('auto.components.stats.ClaudeUsagePane.c3fdbc5474', 'Top model:')}{' '} - {summary?.topModel ?? - translate('auto.components.stats.ClaudeUsagePane.7765a4c3e1', 'n/a')} -

-
-
- {modelBreakdown.slice(0, 5).map((row) => ( -
-
- {row.label} - - {formatTokens(row.inputTokens + row.outputTokens)} - -
-
- {row.sessions}{' '} - {translate('auto.components.stats.ClaudeUsagePane.02a046792e', 'sessions •')}{' '} - {row.turns}{' '} - {translate('auto.components.stats.ClaudeUsagePane.32176e1d44', 'turns')} -
-
- ))} -
-
- -
-
-

- {translate('auto.components.stats.ClaudeUsagePane.7dc9e5613b', 'By project')} -

-

- {translate('auto.components.stats.ClaudeUsagePane.f97435845c', 'Top project:')}{' '} - {summary?.topProject ?? - translate('auto.components.stats.ClaudeUsagePane.7765a4c3e1', 'n/a')} -

-
-
- {projectBreakdown.slice(0, 5).map((row) => ( -
-
- {row.label} - - {formatTokens(row.inputTokens + row.outputTokens)} - -
-
- {row.sessions}{' '} - {translate('auto.components.stats.ClaudeUsagePane.02a046792e', 'sessions •')}{' '} - {row.turns}{' '} - {translate('auto.components.stats.ClaudeUsagePane.32176e1d44', 'turns')} -
-
- ))} -
-
-
- - + )}
diff --git a/src/renderer/src/components/stats/CodexUsageDetails.tsx b/src/renderer/src/components/stats/CodexUsageDetails.tsx new file mode 100644 index 00000000000..bd1d459ce9e --- /dev/null +++ b/src/renderer/src/components/stats/CodexUsageDetails.tsx @@ -0,0 +1,183 @@ +import type { + CodexUsageBreakdownRow, + CodexUsageDailyPoint, + CodexUsageSessionRow, + CodexUsageSummary +} from '../../../../shared/codex-usage-types' +import { CodexUsageDailyChart } from './CodexUsageDailyChart' +import { translate } from '@/i18n/i18n' + +type CodexUsageDetailsProps = { + daily: CodexUsageDailyPoint[] + formatTokens: (value: number) => string + modelBreakdown: CodexUsageBreakdownRow[] + projectBreakdown: CodexUsageBreakdownRow[] + recentSessions: CodexUsageSessionRow[] + summary: CodexUsageSummary | null | undefined +} + +function formatSessionTime(timestamp: string): string { + const parsed = new Date(timestamp) + if (Number.isNaN(parsed.getTime())) { + return timestamp + } + return parsed.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit' + }) +} + +export function CodexUsageDetails({ + daily, + formatTokens, + modelBreakdown, + projectBreakdown, + recentSessions, + summary +}: CodexUsageDetailsProps): React.JSX.Element { + return ( + <> + + +
+ + +
+ +
+
+

+ {translate('auto.components.stats.CodexUsagePane.0cb0983c07', 'Recent sessions')} +

+

+ {translate( + 'auto.components.stats.CodexUsagePane.0bd8655475', + 'Most recent local Codex sessions in this scope.' + )} +

+
+
+ + + + + + + + + + + + + + {recentSessions.map((row) => ( + + + + + + + + + + ))} + +
+ {translate('auto.components.stats.CodexUsagePane.0c36b100be', 'Last active')} + + {translate('auto.components.stats.CodexUsagePane.1a65900aea', 'Project')} + + {translate('auto.components.stats.CodexUsagePane.c2478bcc3c', 'Model')} + + {translate('auto.components.stats.CodexUsagePane.bd0822ca47', 'Events')} + + {translate('auto.components.stats.CodexUsagePane.3acc582214', 'Input')} + + {translate('auto.components.stats.CodexUsagePane.bbd20344b8', 'Output')} + + {translate('auto.components.stats.CodexUsagePane.e0b988599d', 'Total')} +
+ {formatSessionTime(row.lastActiveAt)} + {row.projectLabel} + {row.model ?? + translate('auto.components.stats.CodexUsagePane.bf6cf2d4dd', 'Unknown')} + {row.hasInferredPricing ? ' *' : ''} + {row.events} + {formatTokens(row.inputTokens)} + + {formatTokens(row.outputTokens)} + + {formatTokens(row.totalTokens)} +
+
+
+ + ) +} + +function CodexUsageBreakdownSection({ + formatTokens, + label, + rows, + showInferredPricing, + topLabel, + topValue +}: { + formatTokens: (value: number) => string + label: string + rows: CodexUsageBreakdownRow[] + showInferredPricing: boolean + topLabel: string + topValue: string | null | undefined +}): React.JSX.Element { + return ( +
+
+

{label}

+

+ {topLabel}{' '} + {topValue ?? translate('auto.components.stats.CodexUsagePane.ae255c3dba', 'n/a')} +

+
+
+ {rows.slice(0, 5).map((row) => ( +
+
+ {row.label} + + {formatTokens(row.totalTokens)} + +
+
+ {row.sessions}{' '} + {translate('auto.components.stats.CodexUsagePane.bf1bf2f674', 'sessions •')}{' '} + {row.events} {translate('auto.components.stats.CodexUsagePane.79a69522a5', 'events')} + {showInferredPricing && row.hasInferredPricing + ? ` ${translate( + 'auto.components.stats.CodexUsagePane.247c93ca92', + '• inferred pricing' + )}` + : ''} +
+
+ ))} +
+
+ ) +} diff --git a/src/renderer/src/components/stats/CodexUsagePane.tsx b/src/renderer/src/components/stats/CodexUsagePane.tsx index 8df30ccba81..0d848cdedf2 100644 --- a/src/renderer/src/components/stats/CodexUsagePane.tsx +++ b/src/renderer/src/components/stats/CodexUsagePane.tsx @@ -23,8 +23,7 @@ import { } from '../ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' import { ClaudeUsageLoadingState } from './ClaudeUsageLoadingState' -import { CodexUsageDailyChart } from './CodexUsageDailyChart' -import { CodexUsageRecentSessionsTable } from './CodexUsageRecentSessionsTable' +import { CodexUsageDetails } from './CodexUsageDetails' import { ShareUsageButton } from './ShareUsageButton' import { StatCard } from './StatCard' import { formatCost, formatTokens, formatUpdatedAt } from './usage-formatters' @@ -34,18 +33,30 @@ const RANGE_OPTIONS: CodexUsageRange[] = ['7d', '30d', '90d', 'all'] const SCOPE_OPTIONS: { value: CodexUsageScope; label: string }[] = [ { value: 'orca', - label: translate('auto.components.stats.CodexUsagePane.201766b754', 'Orca worktrees only') + get label() { + return translate('auto.components.stats.CodexUsagePane.201766b754', 'Orca worktrees only') + } }, { value: 'all', - label: translate('auto.components.stats.CodexUsagePane.4fe8820098', 'All local Codex usage') + get label() { + return translate('auto.components.stats.CodexUsagePane.4fe8820098', 'All local Codex usage') + } } ] const RANGE_LABELS: Record = { - '7d': 'Last 7 days', - '30d': 'Last 30 days', - '90d': 'Last 90 days', - all: 'All time' + get '7d'() { + return translate('auto.components.stats.CodexUsagePane.rangeLast7Days', 'Last 7 days') + }, + get '30d'() { + return translate('auto.components.stats.CodexUsagePane.rangeLast30Days', 'Last 30 days') + }, + get '90d'() { + return translate('auto.components.stats.CodexUsagePane.rangeLast90Days', 'Last 90 days') + }, + get all() { + return translate('auto.components.stats.CodexUsagePane.rangeAllTime', 'All time') + } } export function CodexUsagePane(): React.JSX.Element { @@ -292,76 +303,14 @@ export function CodexUsagePane(): React.JSX.Element { )}

- - -
-
-
-

- {translate('auto.components.stats.CodexUsagePane.5a0d1d69cd', 'By model')} -

-

- {translate('auto.components.stats.CodexUsagePane.95d2d89285', 'Top model:')}{' '} - {summary?.topModel ?? - translate('auto.components.stats.CodexUsagePane.ae255c3dba', 'n/a')} -

-
-
- {modelBreakdown.slice(0, 5).map((row) => ( -
-
- {row.label} - - {formatTokens(row.totalTokens)} - -
-
- {row.sessions}{' '} - {translate('auto.components.stats.CodexUsagePane.bf1bf2f674', 'sessions •')}{' '} - {row.events}{' '} - {translate('auto.components.stats.CodexUsagePane.79a69522a5', 'events')} - {row.hasInferredPricing - ? ` ${translate('auto.components.stats.CodexUsagePane.247c93ca92', '• inferred pricing')}` - : ''} -
-
- ))} -
-
- -
-
-

- {translate('auto.components.stats.CodexUsagePane.b98718aaab', 'By project')} -

-

- {translate('auto.components.stats.CodexUsagePane.829ee743f2', 'Top project:')}{' '} - {summary?.topProject ?? - translate('auto.components.stats.CodexUsagePane.ae255c3dba', 'n/a')} -

-
-
- {projectBreakdown.slice(0, 5).map((row) => ( -
-
- {row.label} - - {formatTokens(row.totalTokens)} - -
-
- {row.sessions}{' '} - {translate('auto.components.stats.CodexUsagePane.bf1bf2f674', 'sessions •')}{' '} - {row.events}{' '} - {translate('auto.components.stats.CodexUsagePane.79a69522a5', 'events')} -
-
- ))} -
-
-
- - + )}
diff --git a/src/renderer/src/components/stats/OpenCodeUsageDetails.tsx b/src/renderer/src/components/stats/OpenCodeUsageDetails.tsx new file mode 100644 index 00000000000..48a70c02e96 --- /dev/null +++ b/src/renderer/src/components/stats/OpenCodeUsageDetails.tsx @@ -0,0 +1,186 @@ +import type { + OpenCodeUsageBreakdownRow, + OpenCodeUsageDailyPoint, + OpenCodeUsageSessionRow, + OpenCodeUsageSummary +} from '../../../../shared/opencode-usage-types' +import { CodexUsageDailyChart } from './CodexUsageDailyChart' +import { translate } from '@/i18n/i18n' + +type OpenCodeUsageDetailsProps = { + daily: OpenCodeUsageDailyPoint[] + formatCost: (value: number | null) => string + formatTokens: (value: number) => string + modelBreakdown: OpenCodeUsageBreakdownRow[] + projectBreakdown: OpenCodeUsageBreakdownRow[] + recentSessions: OpenCodeUsageSessionRow[] + summary: OpenCodeUsageSummary | null | undefined +} + +function formatSessionTime(timestamp: string): string { + const parsed = new Date(timestamp) + if (Number.isNaN(parsed.getTime())) { + return timestamp + } + return parsed.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit' + }) +} + +export function OpenCodeUsageDetails({ + daily, + formatCost, + formatTokens, + modelBreakdown, + projectBreakdown, + recentSessions, + summary +}: OpenCodeUsageDetailsProps): React.JSX.Element { + return ( + <> + + +
+ + +
+ +
+
+

+ {translate('auto.components.stats.OpenCodeUsagePane.4799177b1c', 'Recent sessions')} +

+

+ {translate( + 'auto.components.stats.OpenCodeUsagePane.81817a641a', + 'Most recent local OpenCode sessions in this scope.' + )} +

+
+
+ + + + + + + + + + + + + + {recentSessions.map((row) => ( + + + + + + + + + + ))} + +
+ {translate('auto.components.stats.OpenCodeUsagePane.d97bdf6e27', 'Last active')} + + {translate('auto.components.stats.OpenCodeUsagePane.a4738de041', 'Project')} + + {translate('auto.components.stats.OpenCodeUsagePane.08c78441b7', 'Model')} + + {translate('auto.components.stats.OpenCodeUsagePane.d416f5cf92', 'Events')} + + {translate('auto.components.stats.OpenCodeUsagePane.0f2f266c9d', 'Input')} + + {translate('auto.components.stats.OpenCodeUsagePane.dfc4513657', 'Output')} + + {translate('auto.components.stats.OpenCodeUsagePane.349f7c3f5c', 'Total')} +
+ {formatSessionTime(row.lastActiveAt)} + {row.projectLabel} + {row.model ?? + translate('auto.components.stats.OpenCodeUsagePane.362231082f', 'Unknown')} + {row.events} + {formatTokens(row.inputTokens)} + + {formatTokens(row.outputTokens)} + + {formatTokens(row.totalTokens)} +
+
+
+ + ) +} + +function OpenCodeUsageBreakdownSection({ + formatCost, + formatTokens, + label, + rows, + showCost, + topLabel, + topValue +}: { + formatCost: (value: number | null) => string + formatTokens: (value: number) => string + label: string + rows: OpenCodeUsageBreakdownRow[] + showCost: boolean + topLabel: string + topValue: string | null | undefined +}): React.JSX.Element { + return ( +
+
+

{label}

+

+ {topLabel}{' '} + {topValue ?? translate('auto.components.stats.OpenCodeUsagePane.8095a63426', 'n/a')} +

+
+
+ {rows.slice(0, 5).map((row) => ( +
+
+ {row.label} + + {formatTokens(row.totalTokens)} + +
+
+ {row.sessions}{' '} + {translate('auto.components.stats.OpenCodeUsagePane.bc0cb89901', 'sessions •')}{' '} + {row.events}{' '} + {translate('auto.components.stats.OpenCodeUsagePane.1e5d410df0', 'events')} + {showCost && row.estimatedCostUsd !== null + ? ` • ${formatCost(row.estimatedCostUsd)}` + : ''} +
+
+ ))} +
+
+ ) +} diff --git a/src/renderer/src/components/stats/OpenCodeUsagePane.tsx b/src/renderer/src/components/stats/OpenCodeUsagePane.tsx index 721cc3e3fae..1bcee66ef6d 100644 --- a/src/renderer/src/components/stats/OpenCodeUsagePane.tsx +++ b/src/renderer/src/components/stats/OpenCodeUsagePane.tsx @@ -26,8 +26,7 @@ import { } from '../ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' import { ClaudeUsageLoadingState } from './ClaudeUsageLoadingState' -import { CodexUsageDailyChart } from './CodexUsageDailyChart' -import { OpenCodeUsageRecentSessionsTable } from './OpenCodeUsageRecentSessionsTable' +import { OpenCodeUsageDetails } from './OpenCodeUsageDetails' import { StatCard } from './StatCard' import { formatCost, formatTokens, formatUpdatedAt } from './usage-formatters' import { translate } from '@/i18n/i18n' @@ -36,21 +35,33 @@ const RANGE_OPTIONS: OpenCodeUsageRange[] = ['7d', '30d', '90d', 'all'] const SCOPE_OPTIONS: { value: OpenCodeUsageScope; label: string }[] = [ { value: 'orca', - label: translate('auto.components.stats.OpenCodeUsagePane.e04c58327c', 'Orca worktrees only') + get label() { + return translate('auto.components.stats.OpenCodeUsagePane.e04c58327c', 'Orca worktrees only') + } }, { value: 'all', - label: translate( - 'auto.components.stats.OpenCodeUsagePane.144a6050e9', - 'All local OpenCode usage' - ) + get label() { + return translate( + 'auto.components.stats.OpenCodeUsagePane.144a6050e9', + 'All local OpenCode usage' + ) + } } ] const RANGE_LABELS: Record = { - '7d': 'Last 7 days', - '30d': 'Last 30 days', - '90d': 'Last 90 days', - all: 'All time' + get '7d'() { + return translate('auto.components.stats.OpenCodeUsagePane.rangeLast7Days', 'Last 7 days') + }, + get '30d'() { + return translate('auto.components.stats.OpenCodeUsagePane.rangeLast30Days', 'Last 30 days') + }, + get '90d'() { + return translate('auto.components.stats.OpenCodeUsagePane.rangeLast90Days', 'Last 90 days') + }, + get all() { + return translate('auto.components.stats.OpenCodeUsagePane.rangeAllTime', 'All time') + } } export function OpenCodeUsagePane(): React.JSX.Element { @@ -312,82 +323,15 @@ export function OpenCodeUsagePane(): React.JSX.Element { )}

- - -
-
-
-

- {translate('auto.components.stats.OpenCodeUsagePane.040c044d39', 'By model')} -

-

- {translate('auto.components.stats.OpenCodeUsagePane.a15206a63a', 'Top model:')}{' '} - {summary?.topModel ?? - translate('auto.components.stats.OpenCodeUsagePane.8095a63426', 'n/a')} -

-
-
- {modelBreakdown.slice(0, 5).map((row) => ( -
-
- {row.label} - - {formatTokens(row.totalTokens)} - -
-
- {row.sessions}{' '} - {translate( - 'auto.components.stats.OpenCodeUsagePane.bc0cb89901', - 'sessions •' - )}{' '} - {row.events}{' '} - {translate('auto.components.stats.OpenCodeUsagePane.1e5d410df0', 'events')} - {row.estimatedCostUsd !== null - ? ` • ${formatCost(row.estimatedCostUsd)}` - : ''} -
-
- ))} -
-
- -
-
-

- {translate('auto.components.stats.OpenCodeUsagePane.0f0a1684bb', 'By project')} -

-

- {translate('auto.components.stats.OpenCodeUsagePane.048ffe4d65', 'Top project:')}{' '} - {summary?.topProject ?? - translate('auto.components.stats.OpenCodeUsagePane.8095a63426', 'n/a')} -

-
-
- {projectBreakdown.slice(0, 5).map((row) => ( -
-
- {row.label} - - {formatTokens(row.totalTokens)} - -
-
- {row.sessions}{' '} - {translate( - 'auto.components.stats.OpenCodeUsagePane.bc0cb89901', - 'sessions •' - )}{' '} - {row.events}{' '} - {translate('auto.components.stats.OpenCodeUsagePane.1e5d410df0', 'events')} -
-
- ))} -
-
-
- - + )}
diff --git a/src/renderer/src/components/stats/StatsPane.tsx b/src/renderer/src/components/stats/StatsPane.tsx index 78805da66c3..5746d00f8a4 100644 --- a/src/renderer/src/components/stats/StatsPane.tsx +++ b/src/renderer/src/components/stats/StatsPane.tsx @@ -48,10 +48,30 @@ function formatTrackingSince(timestamp: number | null): string { type UsageTab = 'overview' | 'claude' | 'codex' | 'opencode' const USAGE_ANALYTICS_OPTIONS = [ - { id: 'overview', label: translate('auto.components.stats.StatsPane.b2cf4310ce', 'Overview') }, - { id: 'claude', label: translate('auto.components.stats.StatsPane.85457c02fe', 'Claude') }, - { id: 'codex', label: translate('auto.components.stats.StatsPane.7d26110cea', 'Codex') }, - { id: 'opencode', label: translate('auto.components.stats.StatsPane.1e696db2f6', 'OpenCode') } + { + id: 'overview', + get label() { + return translate('auto.components.stats.StatsPane.b2cf4310ce', 'Overview') + } + }, + { + id: 'claude', + get label() { + return translate('auto.components.stats.StatsPane.85457c02fe', 'Claude') + } + }, + { + id: 'codex', + get label() { + return translate('auto.components.stats.StatsPane.7d26110cea', 'Codex') + } + }, + { + id: 'opencode', + get label() { + return translate('auto.components.stats.StatsPane.1e696db2f6', 'OpenCode') + } + } ] as const satisfies readonly { id: UsageTab; label: string }[] function UsageAnalyticsOptionIcon({ tab }: { tab: UsageTab }): React.JSX.Element { diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx index 9aca62ba847..00b1678c954 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx @@ -11,43 +11,63 @@ import { translate } from '@/i18n/i18n' const TAB_COLORS = [ { - label: translate('auto.components.tab.bar.SortableTabContextMenu.20baa43c05', 'None'), + get label() { + return translate('auto.components.tab.bar.SortableTabContextMenu.20baa43c05', 'None') + }, value: null }, { - label: translate('auto.components.tab.bar.SortableTabContextMenu.cb3eadefd2', 'Blue'), + get label() { + return translate('auto.components.tab.bar.SortableTabContextMenu.cb3eadefd2', 'Blue') + }, value: '#3b82f6' }, { - label: translate('auto.components.tab.bar.SortableTabContextMenu.c2d8b0991f', 'Purple'), + get label() { + return translate('auto.components.tab.bar.SortableTabContextMenu.c2d8b0991f', 'Purple') + }, value: '#a855f7' }, { - label: translate('auto.components.tab.bar.SortableTabContextMenu.03cf6dab1a', 'Pink'), + get label() { + return translate('auto.components.tab.bar.SortableTabContextMenu.03cf6dab1a', 'Pink') + }, value: '#ec4899' }, { - label: translate('auto.components.tab.bar.SortableTabContextMenu.620aec6729', 'Red'), + get label() { + return translate('auto.components.tab.bar.SortableTabContextMenu.620aec6729', 'Red') + }, value: '#ef4444' }, { - label: translate('auto.components.tab.bar.SortableTabContextMenu.a47629b3cf', 'Orange'), + get label() { + return translate('auto.components.tab.bar.SortableTabContextMenu.a47629b3cf', 'Orange') + }, value: '#f97316' }, { - label: translate('auto.components.tab.bar.SortableTabContextMenu.69682e2ce4', 'Yellow'), + get label() { + return translate('auto.components.tab.bar.SortableTabContextMenu.69682e2ce4', 'Yellow') + }, value: '#eab308' }, { - label: translate('auto.components.tab.bar.SortableTabContextMenu.be905e9b0a', 'Green'), + get label() { + return translate('auto.components.tab.bar.SortableTabContextMenu.be905e9b0a', 'Green') + }, value: '#22c55e' }, { - label: translate('auto.components.tab.bar.SortableTabContextMenu.845576bed1', 'Teal'), + get label() { + return translate('auto.components.tab.bar.SortableTabContextMenu.845576bed1', 'Teal') + }, value: '#14b8a6' }, { - label: translate('auto.components.tab.bar.SortableTabContextMenu.7703990447', 'Gray'), + get label() { + return translate('auto.components.tab.bar.SortableTabContextMenu.7703990447', 'Gray') + }, value: '#9ca3af' } ] as const diff --git a/src/renderer/src/i18n/no-top-level-translate.test.ts b/src/renderer/src/i18n/no-top-level-translate.test.ts new file mode 100644 index 00000000000..65d6a702db0 --- /dev/null +++ b/src/renderer/src/i18n/no-top-level-translate.test.ts @@ -0,0 +1,82 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { relative, resolve } from 'node:path' +import ts from 'typescript' +import { describe, expect, it } from 'vitest' + +const RENDERER_ROOT = resolve('src/renderer/src') + +const FUNCTION_KINDS = new Set([ + ts.SyntaxKind.FunctionDeclaration, + ts.SyntaxKind.FunctionExpression, + ts.SyntaxKind.ArrowFunction, + ts.SyntaxKind.MethodDeclaration, + ts.SyntaxKind.Constructor, + ts.SyntaxKind.GetAccessor, + ts.SyntaxKind.SetAccessor +]) + +function collectSourceFiles(dir: string, files: string[] = []): string[] { + for (const name of readdirSync(dir)) { + const filePath = resolve(dir, name) + const stat = statSync(filePath) + if (stat.isDirectory()) { + collectSourceFiles(filePath, files) + } else if ( + /\.(ts|tsx)$/.test(name) && + !/\.test\.(ts|tsx)$/.test(name) && + !filePath.includes('/i18n/locales/') + ) { + files.push(filePath) + } + } + return files +} + +function isInsideFunction(node: ts.Node): boolean { + let parent = node.parent + while (parent && parent.kind !== ts.SyntaxKind.SourceFile) { + if (FUNCTION_KINDS.has(parent.kind)) { + return true + } + parent = parent.parent + } + return false +} + +describe('i18n import-time safety', () => { + it('does not evaluate translate() at module load time', () => { + const violations: string[] = [] + + for (const filePath of collectSourceFiles(RENDERER_ROOT)) { + const source = readFileSync(filePath, 'utf8') + if (!source.includes('translate(')) { + continue + } + const sourceFile = ts.createSourceFile( + filePath, + source, + ts.ScriptTarget.Latest, + true, + filePath.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS + ) + + function visit(node: ts.Node): void { + if ( + ts.isCallExpression(node) && + node.expression.getText(sourceFile) === 'translate' && + !isInsideFunction(node) + ) { + const { line, character } = sourceFile.getLineAndCharacterOfPosition( + node.getStart(sourceFile) + ) + violations.push(`${relative(process.cwd(), filePath)}:${line + 1}:${character + 1}`) + } + ts.forEachChild(node, visit) + } + + visit(sourceFile) + } + + expect(violations).toEqual([]) + }) +}) diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 69e07a61aa0..424e838c234 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -2,6 +2,7 @@ import './assets/main.css' import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { useTranslation } from 'react-i18next' import App from './App' import { RecoverableRenderErrorBoundary } from './components/error-boundaries/RecoverableRenderErrorBoundary' import { @@ -35,8 +36,9 @@ if (!rootElement) { throw new Error('Renderer root element not found.') } -createRoot(rootElement).render( - +function RendererRoot(): React.JSX.Element { + useTranslation() + return ( - - - + + ) +} + +createRoot(rootElement).render( + + + + ) recordRendererCrashBreadcrumb('renderer_bootstrap_rendered') diff --git a/src/renderer/src/web/main.tsx b/src/renderer/src/web/main.tsx index 6ebb49937ea..83932348cc8 100644 --- a/src/renderer/src/web/main.tsx +++ b/src/renderer/src/web/main.tsx @@ -2,6 +2,7 @@ import '../assets/main.css' import { lazy, Suspense, useMemo, useState } from 'react' import ReactDOM from 'react-dom/client' +import { useTranslation } from 'react-i18next' import WebConnect from './WebConnect' import { RecoverableRenderErrorBoundary } from '../components/error-boundaries/RecoverableRenderErrorBoundary' import { @@ -46,23 +47,30 @@ function WebRoot(): React.JSX.Element { installWebPreloadApi() return ( }> - - - + ) } +function WebRootBoundary(): React.JSX.Element { + useTranslation() + return ( + + + + ) +} + ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( - - - + + + )