From ffe4e4920c317498e2d23d01f102b613e480d012 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:30:21 -0700 Subject: [PATCH] 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. --- .../components/LinearIssueMarkdownToolbar.tsx | 3 + .../components/NewWorkspaceComposerCard.tsx | 55 +++- .../rich-markdown-slash-command-catalog.tsx | 130 +-------- .../rich-markdown-slash-command-primitives.ts | 117 ++++++++ .../ReviewPRViewAnimatedVisual.tsx | 217 +-------------- .../feature-wall/feature-tour-preview-copy.ts | 31 ++- .../feature-wall/review-pr-view-animation.ts | 261 ++++++++++++++++++ .../components/onboarding/OnboardingFlow.tsx | 2 +- .../SourceControlAiActionRecipeDefaults.tsx | 14 +- .../sidebar/SshDisconnectedDialog.tsx | 44 ++- .../src/components/stats/CodexUsagePane.tsx | 16 +- .../components/stats/OpenCodeUsagePane.tsx | 16 +- 12 files changed, 529 insertions(+), 377 deletions(-) create mode 100644 src/renderer/src/components/editor/rich-markdown-slash-command-primitives.ts create mode 100644 src/renderer/src/components/feature-wall/review-pr-view-animation.ts diff --git a/src/renderer/src/components/LinearIssueMarkdownToolbar.tsx b/src/renderer/src/components/LinearIssueMarkdownToolbar.tsx index aaba565b81d..8ecdf5673c1 100644 --- a/src/renderer/src/components/LinearIssueMarkdownToolbar.tsx +++ b/src/renderer/src/components/LinearIssueMarkdownToolbar.tsx @@ -1,5 +1,6 @@ import React, { useCallback } from 'react' import type { Editor } from '@tiptap/react' +import { useTranslation } from 'react-i18next' import { Bold, Code, @@ -94,6 +95,8 @@ export function LinearIssueMarkdownToolbar({ 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) { diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 4dc44506380..70b29ba4cf6 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -100,21 +100,50 @@ type NewWorkspaceComposerCardProps = { sparseControlsEnabled?: boolean } -const SSH_STATUS_LABEL_FALLBACKS: Partial> = { - 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' +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 { - if (status === 'error') { - return translate('auto.components.NewWorkspaceComposerCard.a239038146', 'SSH connection error') - } - return SSH_STATUS_LABEL_FALLBACKS[status] ?? status + return SSH_STATUS_LABELS[status] ?? status } function SetupCommandPreview({ @@ -308,7 +337,7 @@ export default function NewWorkspaceComposerCard({ }, [eligibleRepos, repoId]) const sshStatusLabel = selectedRepoSshStatus ? getSshStatusLabel(selectedRepoSshStatus) - : 'Not connected' + : translate('auto.components.NewWorkspaceComposerCard.notConnected', 'Not connected') const connectButtonLabel = selectedRepoSshStatus === 'disconnected' || selectedRepoSshStatus === null ? 'Connect' 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 index b52bb9a6811..889ec4e1039 100644 --- a/src/renderer/src/components/editor/rich-markdown-slash-command-catalog.tsx +++ b/src/renderer/src/components/editor/rich-markdown-slash-command-catalog.tsx @@ -1,6 +1,3 @@ -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, @@ -17,119 +14,22 @@ import { } from 'lucide-react' import { translate } from '@/i18n/i18n' -export type SlashMenuState = { - query: string - from: number - to: number - left: number - top: number -} +import { + icon, + insertCodeBlock, + insertTextWithSelection, + insertToggle, + textIcon, + type SlashCommand +} from './rich-markdown-slash-command-primitives' -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 type { + SlashCommand, + SlashCommandGroup, + SlashCommandIcon, + SlashCommandId, + SlashMenuState +} from './rich-markdown-slash-command-primitives' export const slashCommands: SlashCommand[] = [ { 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/feature-wall/ReviewPRViewAnimatedVisual.tsx b/src/renderer/src/components/feature-wall/ReviewPRViewAnimatedVisual.tsx index a0f896f5e5c..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' @@ -121,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. @@ -142,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 (
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 index 86d09ca5e9d..1aa0dc8abbc 100644 --- a/src/renderer/src/components/feature-wall/feature-tour-preview-copy.ts +++ b/src/renderer/src/components/feature-wall/feature-tour-preview-copy.ts @@ -17,8 +17,12 @@ export const FEATURE_TOUR_PREVIEW_COPY: readonly FeatureTourPreviewFrameCopy[] = 'Isolated workspaces' ) }, - caption: - 'Ship several things at once. Each workspace keeps its branch, terminal, and agent activity together.' + 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, @@ -28,7 +32,12 @@ export const FEATURE_TOUR_PREVIEW_COPY: readonly FeatureTourPreviewFrameCopy[] = 'Agent orchestration' ) }, - caption: 'Hand off a goal and walk away. A coordinator agent fans out and ships parallel PRs.' + 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, @@ -38,8 +47,12 @@ export const FEATURE_TOUR_PREVIEW_COPY: readonly FeatureTourPreviewFrameCopy[] = 'GitHub & Linear tasks' ) }, - caption: - 'Skip the tab-switching. Pick from your GitHub or Linear backlog and start a workspace in one click.' + 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, @@ -49,8 +62,12 @@ export const FEATURE_TOUR_PREVIEW_COPY: readonly FeatureTourPreviewFrameCopy[] = 'Splittable terminal' ) }, - caption: - 'Open any workspace to return to its terminal, then split panes for tests, logs, and agents.' + 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.' + ) + } } ] 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/onboarding/OnboardingFlow.tsx b/src/renderer/src/components/onboarding/OnboardingFlow.tsx index d453e97b2da..2605ebd7369 100644 --- a/src/renderer/src/components/onboarding/OnboardingFlow.tsx +++ b/src/renderer/src/components/onboarding/OnboardingFlow.tsx @@ -54,7 +54,7 @@ const stepCopy = { get subtitle() { return translate( 'auto.components.onboarding.OnboardingFlow.ff92d15436', - 'Orca will notify you know when agents are done or need help.' + 'Orca will notify you when agents are done or need help.' ) } }, diff --git a/src/renderer/src/components/settings/SourceControlAiActionRecipeDefaults.tsx b/src/renderer/src/components/settings/SourceControlAiActionRecipeDefaults.tsx index 8076e30455b..c54390e2a8d 100644 --- a/src/renderer/src/components/settings/SourceControlAiActionRecipeDefaults.tsx +++ b/src/renderer/src/components/settings/SourceControlAiActionRecipeDefaults.tsx @@ -142,19 +142,7 @@ export function SourceControlAiActionRecipeDefaults({
diff --git a/src/renderer/src/components/sidebar/SshDisconnectedDialog.tsx b/src/renderer/src/components/sidebar/SshDisconnectedDialog.tsx index d795eb10bb9..21fa58d026d 100644 --- a/src/renderer/src/components/sidebar/SshDisconnectedDialog.tsx +++ b/src/renderer/src/components/sidebar/SshDisconnectedDialog.tsx @@ -24,16 +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.', + 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.' ) }, - 'auth-failed': 'Authentication to the remote host failed.' + get 'auth-failed'() { + return translate( + 'auto.components.sidebar.SshDisconnectedDialog.authFailed', + 'Authentication to the remote host failed.' + ) + } } function isReconnectable(status: SshConnectionStatus): boolean { @@ -78,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/stats/CodexUsagePane.tsx b/src/renderer/src/components/stats/CodexUsagePane.tsx index 566fdd0b42c..0d848cdedf2 100644 --- a/src/renderer/src/components/stats/CodexUsagePane.tsx +++ b/src/renderer/src/components/stats/CodexUsagePane.tsx @@ -45,10 +45,18 @@ const SCOPE_OPTIONS: { value: CodexUsageScope; label: string }[] = [ } ] 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 { diff --git a/src/renderer/src/components/stats/OpenCodeUsagePane.tsx b/src/renderer/src/components/stats/OpenCodeUsagePane.tsx index afe5924cc92..1bcee66ef6d 100644 --- a/src/renderer/src/components/stats/OpenCodeUsagePane.tsx +++ b/src/renderer/src/components/stats/OpenCodeUsagePane.tsx @@ -50,10 +50,18 @@ const SCOPE_OPTIONS: { value: OpenCodeUsageScope; label: string }[] = [ } ] 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 {