From 18ca6919770a100ee38e81f8652bb1419fb3be76 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:45:01 -0700 Subject: [PATCH 1/4] Update create project dialog design (#5231) Co-authored-by: Orca --- .../sidebar/AddRepoCreateStep.test.tsx | 37 +- .../components/sidebar/AddRepoCreateStep.tsx | 349 ++++++------------ .../src/components/sidebar/AddRepoDialog.tsx | 14 +- .../sidebar/AddRepoDialogStepContent.test.tsx | 7 +- .../sidebar/AddRepoDialogStepContent.tsx | 6 - .../sidebar/AddRepoStartSteps.test.tsx | 10 +- .../sidebar/add-repo-local-start-actions.ts | 8 +- .../sidebar/useCreateProjectDefaults.test.ts | 25 +- .../sidebar/useCreateProjectDefaults.ts | 25 +- .../useCreateRepo.default-checkout.test.ts | 8 +- .../src/components/sidebar/useCreateRepo.ts | 11 +- src/renderer/src/i18n/locales/en.json | 14 +- src/renderer/src/i18n/locales/es.json | 14 +- src/renderer/src/i18n/locales/ja.json | 14 +- src/renderer/src/i18n/locales/ko.json | 14 +- src/renderer/src/i18n/locales/zh.json | 14 +- 16 files changed, 219 insertions(+), 351 deletions(-) diff --git a/src/renderer/src/components/sidebar/AddRepoCreateStep.test.tsx b/src/renderer/src/components/sidebar/AddRepoCreateStep.test.tsx index 5aa81139153..4b592ad1a46 100644 --- a/src/renderer/src/components/sidebar/AddRepoCreateStep.test.tsx +++ b/src/renderer/src/components/sidebar/AddRepoCreateStep.test.tsx @@ -3,17 +3,15 @@ import { describe, expect, it, vi } from 'vitest' import { Dialog } from '@/components/ui/dialog' import { TooltipProvider } from '@/components/ui/tooltip' import { CreateStep } from './AddRepoCreateStep' -import type { GitAvailability, RepoKind } from './create-project-defaults' +import type { GitAvailability } from './create-project-defaults' function renderCreateStep({ createName = '', - createKind = 'git', gitAvailability = 'available', createParent = '/Users/alice/orca/projects', parentDefaultPending = false }: { createName?: string - createKind?: RepoKind gitAvailability?: GitAvailability createParent?: string parentDefaultPending?: boolean @@ -24,7 +22,6 @@ function renderCreateStep({ @@ -43,24 +39,31 @@ function renderCreateStep({ } describe('CreateStep', () => { - it('renders the name-first create UI with advanced controls collapsed', () => { + it('renders the conductor-style Git project form without templates or kind selection', () => { const html = renderCreateStep() - expect(html).toContain('Create a new project') - expect(html).toContain('Name') - expect(html).toContain('Git repository in ~/orca/projects') - // The summary card itself is the collapsed disclosure for the uncommon settings. - expect(html).toContain('aria-expanded="false"') + expect(html).toContain('Create project') + expect(html).toContain('Project name') + expect(html).not.toContain('Git repo:') + expect(html).not.toContain('>project-name') + expect(html).toContain('Parent folder') + expect(html).toContain('Browse') + expect(html).not.toContain('Template') expect(html).not.toContain('Project kind') - expect(html).not.toContain('Location') - expect(html).not.toContain('aria-label="Browse server filesystem"') }) - it('shows the Git fallback explanation in the collapsed summary', () => { - const html = renderCreateStep({ createKind: 'folder', gitAvailability: 'unavailable' }) + it('shows the repo name in the helper only after a project name is entered', () => { + const html = renderCreateStep({ createName: 'demo-project' }) - expect(html).toContain('Folder in ~/orca/projects') - expect(html).toContain('Git isn't installed, so a plain folder is the default.') + expect(html).toContain('Git repo:') + expect(html).toContain('demo-project') + }) + + it('requires Git instead of falling back to folder creation', () => { + const html = renderCreateStep({ gitAvailability: 'unavailable' }) + + expect(html).toContain('Git is required to create a project.') + expect(html).toContain('disabled=""') }) it('disables create while an auto-filled parent belongs to a previous target', () => { diff --git a/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx b/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx index 863b1121238..8fd3e75d196 100644 --- a/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx +++ b/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx @@ -1,30 +1,17 @@ // Step for AddRepoDialog (orca#763), split out so create-project state stays scoped. -import React, { useCallback, useMemo, useRef, useState } from 'react' -import { ChevronDown, Folder, GitBranch, Loader2 } from 'lucide-react' +import React, { useMemo, useState } from 'react' +import { CornerDownLeft, FolderOpen, Loader2 } from 'lucide-react' import { DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { cn } from '@/lib/utils' -import { - CreateProjectLocationField, - CreateProjectParentBrowser -} from './CreateProjectLocationField' +import { CreateProjectParentBrowser } from './CreateProjectLocationField' import { translate } from '@/i18n/i18n' -import { - formatCreateProjectParentSummary, - joinCreateProjectPath, - type GitAvailability, - type RepoKind -} from './create-project-defaults' - -// ── UI helpers ─────────────────────────────────────────────────────── - -const CREATE_PROJECT_NAME_PLACEHOLDER = 'project-name' +import { getScreenSubmitModifierLabel } from '@/lib/screen-submit-shortcut' +import { formatCreateProjectParentSummary, type GitAvailability } from './create-project-defaults' type CreateStepProps = { createName: string createParent: string - createKind: RepoKind createError: string | null isCreating: boolean defaultParent?: string @@ -35,7 +22,6 @@ type CreateStepProps = { runtimeEnvironmentId?: string | null onNameChange: (value: string) => void onParentChange: (value: string) => void - onKindChange: (kind: RepoKind) => void onPickParent: () => void onCreate: () => void } @@ -43,7 +29,6 @@ type CreateStepProps = { export function CreateStep({ createName, createParent, - createKind, createError, isCreating, defaultParent = '', @@ -54,52 +39,16 @@ export function CreateStep({ runtimeEnvironmentId, onNameChange, onParentChange, - onKindChange, onPickParent, onCreate }: CreateStepProps): React.JSX.Element { - const radioGroupRef = useRef(null) - const radioFocusFrameRef = useRef(null) const [browsingParent, setBrowsingParent] = useState(false) - const [advancedOpen, setAdvancedOpen] = useState(false) - - const cancelRadioFocusFrame = useCallback((): void => { - if (radioFocusFrameRef.current === null) { - return - } - cancelAnimationFrame(radioFocusFrameRef.current) - radioFocusFrameRef.current = null - }, []) - - const setRadioGroupNode = useCallback( - (node: HTMLDivElement | null): void => { - // Why: the queued arrow-key focus is only valid while this radiogroup is mounted. - if (!node) { - cancelRadioFocusFrame() - } - radioGroupRef.current = node - }, - [cancelRadioFocusFrame] - ) - - // Arrow keys cycle selection within the radiogroup (WAI-ARIA radio pattern). - const cycleKind = useCallback(() => { - const next = createKind === 'git' ? 'folder' : 'git' - onKindChange(next) - cancelRadioFocusFrame() - radioFocusFrameRef.current = requestAnimationFrame(() => { - radioFocusFrameRef.current = null - const nextEl = radioGroupRef.current?.querySelector( - `[data-kind="${next}"]` - ) - nextEl?.focus() - }) - }, [cancelRadioFocusFrame, createKind, onKindChange]) const canSubmit = createName.trim().length > 0 && createParent.trim().length > 0 && gitAvailability !== 'checking' && + gitAvailability !== 'unavailable' && !parentDefaultPending && !isCreating const missingLocationLabel = translate( @@ -111,7 +60,7 @@ export function CreateStep({ 'server folder not selected' ) - const summaryParent = useMemo( + const parentSummary = useMemo( () => formatCreateProjectParentSummary({ parent: createParent, @@ -128,14 +77,8 @@ export function CreateStep({ runtimeEnvironmentId ] ) - const targetPathPreview = useMemo(() => { - const name = createName.trim() || CREATE_PROJECT_NAME_PLACEHOLDER - return createParent.trim() ? joinCreateProjectPath(createParent, name) : '' - }, [createName, createParent]) - const kindLabel = - createKind === 'git' - ? translate('auto.components.sidebar.AddRepoCreateStep.11fd2a7db8', 'Git repository') - : translate('auto.components.sidebar.AddRepoCreateStep.038729c107', 'Folder') + const repoNamePreview = createName.trim() + const submitShortcutModifierLabel = getScreenSubmitModifierLabel() const showGitFallback = gitAvailability === 'unavailable' const showGitChecking = gitAvailability === 'checking' const showRuntimeMissingParent = @@ -157,14 +100,14 @@ export function CreateStep({ {translate( - 'auto.components.sidebar.AddRepoCreateStep.c7b9f94456', - 'Create a new project' + 'auto.components.sidebar.AddRepoCreateStep.createProjectTitle', + 'Create project' )} {translate( - 'auto.components.sidebar.AddRepoCreateStep.b100311784', - 'Name it and Orca will create a real project with sensible defaults.' + 'auto.components.sidebar.AddRepoCreateStep.createProjectDescription', + 'Create a local Git repo and first workspace.' )} @@ -173,14 +116,16 @@ export function CreateStep({ (= content size), so a long path inside the Location row would blow out the dialog width even with flex + truncate on the row itself. min-w-0 here caps the grid track at the dialog's max-width. */} -
- {/* Name. Monospaced because it ends up as a directory name. */} -
+
+
+ {repoNamePreview ? ( +

+ {translate( + 'auto.components.sidebar.AddRepoCreateStep.createsGitRepoHelp', + 'Git repo:' + )}{' '} + {repoNamePreview} +

+ ) : null}
- {/* Summary card doubles as the disclosure for the uncommon settings, so the - defaults and the controls to change them live in one place. */} -
- - - {advancedOpen && ( -
- {/* Real radiogroup so screen readers announce the segmented choice. */} -
- - {translate( - 'auto.components.sidebar.AddRepoCreateStep.180e9b5e48', - 'Project kind' - )} - -
- {(['git', 'folder'] as const).map((kind) => { - const selected = createKind === kind - const label = - kind === 'git' - ? translate( - 'auto.components.sidebar.AddRepoCreateStep.11fd2a7db8', - 'Git repository' - ) - : translate( - 'auto.components.sidebar.AddRepoCreateStep.038729c107', - 'Folder' - ) - const Icon = kind === 'git' ? GitBranch : Folder - return ( - - ) - })} -
- {showGitFallback && ( -

- {translate( - 'auto.components.sidebar.AddRepoCreateStep.fe1e616c5b', - "Git isn't installed, so a plain folder is the default." - )} -

- )} -
- - {/* The local picker returns client paths; runtime servers browse host paths via RPC. */} - setBrowsingParent(true)} - /> - - {targetPathPreview && ( -

- {targetPathPreview} -

+ +
+ {showGitChecking ? ( +

+ + {translate( + 'auto.components.sidebar.AddRepoCreateStep.2a762f3b19', + 'Checking Git on this host...' )} -

+

+ ) : showGitFallback ? ( +

+ {translate( + 'auto.components.sidebar.AddRepoCreateStep.gitRequiredError', + 'Git is required to create a project.' + )} +

+ ) : showRuntimeMissingParent ? ( +

+ {translate( + 'auto.components.sidebar.AddRepoCreateStep.c234df77f7', + 'Choose or enter a server parent folder before creating.' + )} +

+ ) : ( +

+ {parentSummary} +

)}
{createError && ( -

+

{createError}

)} - +
+ +
) diff --git a/src/renderer/src/components/sidebar/AddRepoDialog.tsx b/src/renderer/src/components/sidebar/AddRepoDialog.tsx index 5bf06950be3..9d2c17a12b7 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialog.tsx @@ -133,12 +133,10 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { const { createName, createParent, - createKind, createError, isCreating, setCreateName, setCreateParent, - setCreateKind, setCreateError, resetCreateState, handlePickParent, @@ -153,14 +151,12 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { createRuntimeParentStatus, createParentDefaultPending, resetCreateDefaultState, - markCreateParentTouched, - markCreateKindTouched + markCreateParentTouched } = useCreateProjectDefaults({ step, activeRuntimeEnvironmentId: settings?.activeRuntimeEnvironmentId, createParent, - setCreateParent, - setCreateKind + setCreateParent }) const { @@ -332,7 +328,6 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { nestedGroupName={nestedGroupName} createName={createName} createParent={createParent} - createKind={createKind} createError={createError} isCreating={isCreating} createDefaultParent={createDefaultParent} @@ -390,11 +385,6 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { setCreateParent(value) setCreateError(null) }} - onCreateKindChange={(kind) => { - markCreateKindTouched() - setCreateKind(kind) - setCreateError(null) - }} onPickCreateParent={() => { void handlePickParent().then((dir) => { if (dir) { diff --git a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx index f422bb391f1..b295293c100 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.test.tsx @@ -53,7 +53,6 @@ function renderStepContent(overrides: Partial): string { nestedGroupName: 'platform', createName: '', createParent: '', - createKind: 'git', createError: null, isCreating: false, createDefaultParent: '', @@ -82,7 +81,6 @@ function renderStepContent(overrides: Partial): string { onImportNestedRepos: vi.fn(), onCreateNameChange: vi.fn(), onCreateParentChange: vi.fn(), - onCreateKindChange: vi.fn(), onPickCreateParent: vi.fn(), onCreate: vi.fn(), ...overrides @@ -129,8 +127,9 @@ describe('AddRepoDialogStepContent nested imports', () => { activeRuntimeEnvironmentId: 'env-1' }) - expect(html).toContain('Create a new project') - expect(html).toContain('server folder not selected') + expect(html).toContain('Create project') + expect(html).toContain('Choose or enter a server parent folder before creating.') + expect(html).toContain('Browse') }) it('offers server browsing for remote clone destinations', () => { diff --git a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx index dc09458f42b..baa39686ba7 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialogStepContent.tsx @@ -38,7 +38,6 @@ type AddRepoDialogStepContentProps = { nestedGroupName: string createName: string createParent: string - createKind: 'git' | 'folder' createError: string | null isCreating: boolean createDefaultParent: string @@ -67,7 +66,6 @@ type AddRepoDialogStepContentProps = { onImportNestedRepos: (mode: 'group' | 'separate') => void onCreateNameChange: (name: string) => void onCreateParentChange: (parent: string) => void - onCreateKindChange: (kind: 'git' | 'folder') => void onPickCreateParent: () => void onCreate: () => void } @@ -100,7 +98,6 @@ export function AddRepoDialogStepContent({ nestedGroupName, createName, createParent, - createKind, createError, isCreating, createDefaultParent, @@ -129,7 +126,6 @@ export function AddRepoDialogStepContent({ onImportNestedRepos, onCreateNameChange, onCreateParentChange, - onCreateKindChange, onPickCreateParent, onCreate }: AddRepoDialogStepContentProps): React.JSX.Element | null { @@ -224,7 +220,6 @@ export function AddRepoDialogStepContent({ diff --git a/src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx b/src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx index ace7b22bc85..62cfae41daa 100644 --- a/src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx +++ b/src/renderer/src/components/sidebar/AddRepoStartSteps.test.tsx @@ -131,7 +131,7 @@ describe('AddRepoLocalStartStep', () => { expect(markup).toContain('Browse folder') expect(markup).toContain('Clone from URL') expect(markup).toContain('Remote project') - expect(markup).toContain('Create new project') + expect(markup).toContain('Create project') expect(markup).toContain('Other ways to add') expect(markup).not.toContain('More options') }) @@ -140,7 +140,7 @@ describe('AddRepoLocalStartStep', () => { const titles = getActionTitles(false) expect(titles.primary).toBe('Browse folder') - expect(titles.secondary).toEqual(['Clone from URL', 'Remote project', 'Create new project']) + expect(titles.secondary).toEqual(['Clone from URL', 'Remote project', 'Create project']) }) it('keeps Browse folder primary for SSH-likely users', () => { @@ -149,14 +149,14 @@ describe('AddRepoLocalStartStep', () => { expect(markup).toContain('Browse folder') expect(markup).toContain('Remote project') expect(markup).toContain('Clone from URL') - expect(markup).toContain('Create new project') + expect(markup).toContain('Create project') }) it('orders secondary actions remote-first for SSH-likely users', () => { const titles = getActionTitles(true) expect(titles.primary).toBe('Browse folder') - expect(titles.secondary).toEqual(['Remote project', 'Clone from URL', 'Create new project']) + expect(titles.secondary).toEqual(['Remote project', 'Clone from URL', 'Create project']) }) it('focuses Browse folder when the default Add Project step opens', async () => { @@ -188,7 +188,7 @@ describe('AddRepoLocalStartStep', () => { expect(findButton(container, 'Clone from URL').disabled).toBe(false) expect(findButton(container, 'Remote project').disabled).toBe(false) - expect(findButton(container, 'Create new project').disabled).toBe(false) + expect(findButton(container, 'Create project').disabled).toBe(false) await act(async () => { root.unmount() diff --git a/src/renderer/src/components/sidebar/add-repo-local-start-actions.ts b/src/renderer/src/components/sidebar/add-repo-local-start-actions.ts index 259b6b8f3b7..0572b1dc236 100644 --- a/src/renderer/src/components/sidebar/add-repo-local-start-actions.ts +++ b/src/renderer/src/components/sidebar/add-repo-local-start-actions.ts @@ -71,12 +71,12 @@ export function getAddRepoLocalStartActions({ kind: 'create' as const, icon: Plus, title: translate( - 'auto.components.sidebar.add.repo.local.start.actions.c709860596', - 'Create new project' + 'auto.components.sidebar.add.repo.local.start.actions.createProjectTitle', + 'Create project' ), description: translate( - 'auto.components.sidebar.add.repo.local.start.actions.d72789705e', - 'Start from an empty folder' + 'auto.components.sidebar.add.repo.local.start.actions.createGitProjectDescription', + 'Create a local Git repository' ), onClick: onOpenCreateStep } diff --git a/src/renderer/src/components/sidebar/useCreateProjectDefaults.test.ts b/src/renderer/src/components/sidebar/useCreateProjectDefaults.test.ts index 3508458daa4..941362ca5e8 100644 --- a/src/renderer/src/components/sidebar/useCreateProjectDefaults.test.ts +++ b/src/renderer/src/components/sidebar/useCreateProjectDefaults.test.ts @@ -63,16 +63,14 @@ function useHarness(overrides: Partial { @@ -93,16 +91,15 @@ describe('useCreateProjectDefaults', () => { mocks.getDefaultCreateProjectParent.mockResolvedValue('/Users/alice/orca/projects') }) - it('auto-fills the local default parent and defaults to git when available', async () => { + it('auto-fills the local default parent and records Git availability', async () => { mocks.isGitAvailable.mockResolvedValue(true) - const { setCreateParent, setCreateKind } = useHarness() + const { setCreateParent } = useHarness() await flushAsync() expect(setCreateParent).toHaveBeenCalledWith('/Users/alice/orca/projects') expect(mocks.stateValues[DEFAULT_PARENT_STATE]).toBe('/Users/alice/orca/projects') expect(mocks.stateValues[GIT_AVAILABILITY_STATE]).toBe('available') - expect(setCreateKind).toHaveBeenCalledWith('git') expect(mocks.getDefaultCreateProjectParent).toHaveBeenCalled() expect(mocks.callRuntimeRpc).not.toHaveBeenCalled() }) @@ -128,24 +125,22 @@ describe('useCreateProjectDefaults', () => { expect(mocks.stateValues[DEFAULT_PARENT_STATE]).toBe('/Users/alice/orca/projects') }) - it('defaults to folder with a visible fallback when Git is unavailable', async () => { + it('records unavailable Git without changing project kind', async () => { mocks.isGitAvailable.mockResolvedValue(false) - const { setCreateKind } = useHarness() + useHarness() await flushAsync() expect(mocks.stateValues[GIT_AVAILABILITY_STATE]).toBe('unavailable') - expect(setCreateKind).toHaveBeenCalledWith('folder') }) - it('reports unknown availability and keeps the kind when the Git probe fails', async () => { + it('reports unknown availability when the Git probe fails', async () => { mocks.isGitAvailable.mockRejectedValue(new Error('probe failed')) - const { setCreateKind } = useHarness() + useHarness() await flushAsync() expect(mocks.stateValues[GIT_AVAILABILITY_STATE]).toBe('unknown') - expect(setCreateKind).not.toHaveBeenCalled() }) it('does not overwrite a parent the user already chose', async () => { @@ -161,7 +156,7 @@ describe('useCreateProjectDefaults', () => { mocks.browseRuntimeServerDirectory.mockResolvedValue({ resolvedPath: '/home/alice' }) mocks.callRuntimeRpc.mockResolvedValue({ available: true }) - const { setCreateParent, setCreateKind } = useHarness({ activeRuntimeEnvironmentId: 'env-1' }) + const { setCreateParent } = useHarness({ activeRuntimeEnvironmentId: 'env-1' }) await flushAsync() expect(mocks.browseRuntimeServerDirectory).toHaveBeenCalledWith('env-1', '~') @@ -176,7 +171,6 @@ describe('useCreateProjectDefaults', () => { { timeoutMs: 3000 } ) expect(mocks.isGitAvailable).not.toHaveBeenCalled() - expect(setCreateKind).toHaveBeenCalledWith('git') }) it('replaces an untouched local default when switching to a runtime target', async () => { @@ -249,11 +243,10 @@ describe('useCreateProjectDefaults', () => { }) it('does nothing outside the create step', async () => { - const { setCreateParent, setCreateKind } = useHarness({ step: 'add' }) + const { setCreateParent } = useHarness({ step: 'add' }) await flushAsync() expect(setCreateParent).not.toHaveBeenCalled() - expect(setCreateKind).not.toHaveBeenCalled() expect(mocks.isGitAvailable).not.toHaveBeenCalled() expect(mocks.browseRuntimeServerDirectory).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/components/sidebar/useCreateProjectDefaults.ts b/src/renderer/src/components/sidebar/useCreateProjectDefaults.ts index 9c94cba1629..f93e61ee3d1 100644 --- a/src/renderer/src/components/sidebar/useCreateProjectDefaults.ts +++ b/src/renderer/src/components/sidebar/useCreateProjectDefaults.ts @@ -5,11 +5,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { browseRuntimeServerDirectory } from '@/runtime/runtime-server-directory-browser' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import type { AddRepoDialogStep } from './add-repo-dialog-types' -import { - getDefaultCreateProjectParent, - type GitAvailability, - type RepoKind -} from './create-project-defaults' +import { getDefaultCreateProjectParent, type GitAvailability } from './create-project-defaults' const LOCAL_GIT_AVAILABILITY_TIMEOUT_MS = 1500 const RUNTIME_GIT_AVAILABILITY_TIMEOUT_MS = 3000 @@ -51,14 +47,12 @@ export function useCreateProjectDefaults({ step, activeRuntimeEnvironmentId, createParent, - setCreateParent, - setCreateKind + setCreateParent }: { step: AddRepoDialogStep activeRuntimeEnvironmentId: string | null | undefined createParent: string setCreateParent: (value: string) => void - setCreateKind: (kind: RepoKind) => void }): { createDefaultParent: string createGitAvailability: GitAvailability @@ -66,7 +60,6 @@ export function useCreateProjectDefaults({ createParentDefaultPending: boolean resetCreateDefaultState: () => void markCreateParentTouched: (value?: string) => void - markCreateKindTouched: () => void } { const [createDefaultParent, setCreateDefaultParent] = useState('') const [createGitAvailability, setCreateGitAvailability] = useState('unknown') @@ -76,7 +69,6 @@ export function useCreateProjectDefaults({ const autoFilledCreateParentRef = useRef(null) const createParentProvenanceRef = useRef(null) const createParentTouchedRef = useRef(false) - const createKindTouchedRef = useRef(false) const createParentDefaultGenRef = useRef(0) const createGitProbeGenRef = useRef(0) const activeCreateParentRuntimeEnvironmentId = activeRuntimeEnvironmentId?.trim() || null @@ -96,7 +88,6 @@ export function useCreateProjectDefaults({ autoFilledCreateParentRef.current = null createParentProvenanceRef.current = null createParentTouchedRef.current = false - createKindTouchedRef.current = false setCreateDefaultParent('') setCreateGitAvailability('unknown') setCreateRuntimeParentStatus('idle') @@ -114,9 +105,6 @@ export function useCreateProjectDefaults({ }, [activeCreateParentRuntimeEnvironmentId, createParent] ) - const markCreateKindTouched = useCallback(() => { - createKindTouchedRef.current = true - }, []) const createParentDefaultPending = step === 'create' && @@ -282,10 +270,6 @@ export function useCreateProjectDefaults({ return } setCreateGitAvailability(available ? 'available' : 'unavailable') - if (createKindTouchedRef.current) { - return - } - setCreateKind(available ? 'git' : 'folder') }) .catch(() => { if (gen !== createGitProbeGenRef.current) { @@ -293,7 +277,7 @@ export function useCreateProjectDefaults({ } setCreateGitAvailability('unknown') }) - }, [activeRuntimeEnvironmentId, setCreateKind, step]) + }, [activeRuntimeEnvironmentId, step]) return { createDefaultParent, @@ -301,7 +285,6 @@ export function useCreateProjectDefaults({ createRuntimeParentStatus, createParentDefaultPending: createParentPending, resetCreateDefaultState, - markCreateParentTouched, - markCreateKindTouched + markCreateParentTouched } } diff --git a/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts b/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts index 4afc7041c91..78a26a3848c 100644 --- a/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts +++ b/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts @@ -89,7 +89,7 @@ describe('useCreateRepo default-checkout handoff', () => { vi.clearAllMocks() mocks.stateIndex = 0 mocks.stateSetters = [] - mocks.stateValues = ['created', '/projects', 'git', null, false] + mocks.stateValues = ['created', '/projects', null, false] mocks.storeState.repos = [] mocks.storeState.worktreesByRepo = {} mocks.storeState.settings.activeRuntimeEnvironmentId = null @@ -163,11 +163,11 @@ describe('useCreateRepo default-checkout handoff', () => { ) }) - it('marks onboarding folder progress when a created folder project opens', async () => { + it('uses the folder completion path if IPC returns a folder project', async () => { const repo = makeRepo({ kind: 'folder' }) const worktree = { id: `${repo.id}::/projects/created` } const closeModal = vi.fn() - mocks.stateValues = ['created', '/projects', 'folder', null, false] + mocks.stateValues = ['created', '/projects', null, false] mocks.createRepo.mockResolvedValue({ repo }) mocks.fetchWorktrees.mockImplementation(async (repoId: string) => { mocks.storeState.worktreesByRepo = { [repoId]: [worktree] } @@ -181,7 +181,7 @@ describe('useCreateRepo default-checkout handoff', () => { expect(mocks.createRepo).toHaveBeenCalledWith({ parentPath: '/projects', name: 'created', - kind: 'folder' + kind: 'git' }) expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id) expect(mocks.activateAndRevealWorktree).toHaveBeenCalledWith(worktree.id, { diff --git a/src/renderer/src/components/sidebar/useCreateRepo.ts b/src/renderer/src/components/sidebar/useCreateRepo.ts index bb12da03eb7..9d9aa95e418 100644 --- a/src/renderer/src/components/sidebar/useCreateRepo.ts +++ b/src/renderer/src/components/sidebar/useCreateRepo.ts @@ -10,7 +10,6 @@ import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-cl import { isGitRepoKind } from '../../../../shared/repo-kind' import type { Repo } from '../../../../shared/types' import { translate } from '@/i18n/i18n' -import type { RepoKind } from './create-project-defaults' export function useCreateRepo( fetchWorktrees: ( @@ -22,7 +21,6 @@ export function useCreateRepo( ) { const [createName, setCreateName] = useState('') const [createParent, setCreateParent] = useState('') - const [createKind, setCreateKind] = useState('git') const [createError, setCreateError] = useState(null) const [isCreating, setIsCreating] = useState(false) const mountedRef = useMountedRef() @@ -36,7 +34,6 @@ export function useCreateRepo( createGenRef.current++ setCreateName('') setCreateParent('') - setCreateKind('git') setCreateError(null) setIsCreating(false) }, []) @@ -82,14 +79,14 @@ export function useCreateRepo( { parentPath, name, - kind: createKind + kind: 'git' }, { timeoutMs: 60_000 } ) : await window.api.repos.create({ parentPath, name, - kind: createKind + kind: 'git' }) // Why: if the user closed the dialog or clicked Back mid-create, // createGenRef was bumped by resetCreateState. Ignore stale results. @@ -170,17 +167,15 @@ export function useCreateRepo( setIsCreating(false) } } - }, [createName, createParent, createKind, fetchWorktrees, mountedRef, closeModal, onGitRepoReady]) + }, [createName, createParent, fetchWorktrees, mountedRef, closeModal, onGitRepoReady]) return { createName, createParent, - createKind, createError, isCreating, setCreateName, setCreateParent, - setCreateKind, setCreateError, resetCreateState, handlePickParent, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 8a7d2103a28..59d81ba2579 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3062,7 +3062,15 @@ "2c12db1511": "Project already added", "875dda0995": "Enter a server parent path.", "45b7c26034": "Create project", - "85085d74d2": "Creating…" + "85085d74d2": "Creating…", + "createProjectTitle": "Create project", + "createProjectDescription": "Create a local Git repo and first workspace.", + "projectNameLabel": "Project name", + "createsGitRepoHelp": "Git repo:", + "parentFolderLabel": "Parent folder", + "browseParentFolder": "Browse", + "gitRequiredError": "Git is required to create a project.", + "createAction": "Create" }, "AddRepoNestedImportStep": { "496f68cf8c": "Scanning repositories. Click to stop.", @@ -3663,7 +3671,9 @@ "a6c20dca96": "Open a project from an SSH target", "3d162cc76f": "Remote project", "fb4fc5380e": "Local project, Git repo, or folder with many repos", - "2281fdc8c7": "Browse folder" + "2281fdc8c7": "Browse folder", + "createProjectTitle": "Create project", + "createGitProjectDescription": "Create a local Git repository" } } } diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index b12bdc671e9..65cbbcee527 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3062,7 +3062,15 @@ "fe1e616c5b": "Git no está instalado, así que una carpeta simple será el valor predeterminado.", "c234df77f7": "Elige o introduce una carpeta principal del servidor antes de crear.", "3a13f6e88b": "ubicación no seleccionada", - "6ed14c0281": "carpeta del servidor no seleccionada" + "6ed14c0281": "carpeta del servidor no seleccionada", + "createProjectTitle": "Crear proyecto", + "createProjectDescription": "Crea un repositorio Git local y el primer espacio de trabajo.", + "projectNameLabel": "Nombre del proyecto", + "createsGitRepoHelp": "Repositorio Git:", + "parentFolderLabel": "Carpeta principal", + "browseParentFolder": "Explorar", + "gitRequiredError": "Git es necesario para crear un proyecto.", + "createAction": "Crear" }, "AddRepoNestedImportStep": { "496f68cf8c": "Escaneo de repositorios. Haga clic para detener.", @@ -3663,7 +3671,9 @@ "a6c20dca96": "Abrir un proyecto desde un destino SSH", "3d162cc76f": "Proyecto remoto", "fb4fc5380e": "Proyecto local, repo de Git o carpeta con muchos repos", - "2281fdc8c7": "Explorar carpeta" + "2281fdc8c7": "Explorar carpeta", + "createProjectTitle": "Crear proyecto", + "createGitProjectDescription": "Crear un repositorio Git local" } } } diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index fc3ac2b0837..888ce576bb0 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3043,7 +3043,15 @@ "fe1e616c5b": "Git がインストールされていないため、通常のフォルダが既定になります。", "c234df77f7": "作成する前に、サーバーの親フォルダを選択または入力してください。", "3a13f6e88b": "場所が選択されていません", - "6ed14c0281": "サーバーフォルダが選択されていません" + "6ed14c0281": "サーバーフォルダが選択されていません", + "createProjectTitle": "プロジェクトの作成", + "createProjectDescription": "ローカルの Git リポジトリと最初のワークスペースを作成します。", + "projectNameLabel": "プロジェクト名", + "createsGitRepoHelp": "Git リポジトリ:", + "parentFolderLabel": "親フォルダ", + "browseParentFolder": "参照", + "gitRequiredError": "プロジェクトを作成するには Git が必要です。", + "createAction": "作成" }, "AddRepoNestedImportStep": { "496f68cf8c": "リポジトリをスキャンしています。クリックして停止します。", @@ -3644,7 +3652,9 @@ "a6c20dca96": "SSH ターゲットからプロジェクトを開く", "3d162cc76f": "リモートプロジェクト", "fb4fc5380e": "ローカルプロジェクト、Git repo、または多数の repos を含むフォルダー", - "2281fdc8c7": "フォルダを参照する" + "2281fdc8c7": "フォルダを参照する", + "createProjectTitle": "プロジェクトの作成", + "createGitProjectDescription": "ローカルの Git リポジトリを作成" } } } diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index a70b8cd7374..8b34556d89c 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3043,7 +3043,15 @@ "fe1e616c5b": "Git이 설치되어 있지 않아 일반 폴더가 기본값입니다.", "c234df77f7": "만들기 전에 서버 상위 폴더를 선택하거나 입력하세요.", "3a13f6e88b": "위치가 선택되지 않음", - "6ed14c0281": "서버 폴더가 선택되지 않음" + "6ed14c0281": "서버 폴더가 선택되지 않음", + "createProjectTitle": "프로젝트 생성", + "createProjectDescription": "로컬 Git 저장소와 첫 번째 작업 공간을 만듭니다.", + "projectNameLabel": "프로젝트 이름", + "createsGitRepoHelp": "Git 저장소:", + "parentFolderLabel": "상위 폴더", + "browseParentFolder": "찾아보기", + "gitRequiredError": "프로젝트를 만들려면 Git이 필요합니다.", + "createAction": "생성" }, "AddRepoNestedImportStep": { "496f68cf8c": "저장소를 스캔하는 중입니다. 중지하려면 클릭하세요.", @@ -3644,7 +3652,9 @@ "a6c20dca96": "SSH 대상에서 프로젝트 열기", "3d162cc76f": "원격 프로젝트", "fb4fc5380e": "로컬 프로젝트, Git repo 또는 repos가 많은 폴더", - "2281fdc8c7": "폴더 찾아보기" + "2281fdc8c7": "폴더 찾아보기", + "createProjectTitle": "프로젝트 생성", + "createGitProjectDescription": "로컬 Git 저장소 생성" } } } diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 7fba13602df..50187293a2e 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3043,7 +3043,15 @@ "fe1e616c5b": "未安装 Git,因此默认创建普通文件夹。", "c234df77f7": "创建前请选择或输入服务器父文件夹。", "3a13f6e88b": "未选择位置", - "6ed14c0281": "未选择服务器文件夹" + "6ed14c0281": "未选择服务器文件夹", + "createProjectTitle": "创建项目", + "createProjectDescription": "创建本地 Git 存储库和第一个工作区。", + "projectNameLabel": "项目名称", + "createsGitRepoHelp": "Git 存储库:", + "parentFolderLabel": "父文件夹", + "browseParentFolder": "浏览", + "gitRequiredError": "创建项目需要 Git。", + "createAction": "创建" }, "AddRepoNestedImportStep": { "496f68cf8c": "扫描存储库。单击停止。", @@ -3644,7 +3652,9 @@ "a6c20dca96": "从 SSH 目标打开项目", "3d162cc76f": "远程项目", "fb4fc5380e": "本地项目、Git repo 或包含多个 repos 的文件夹", - "2281fdc8c7": "浏览文件夹" + "2281fdc8c7": "浏览文件夹", + "createProjectTitle": "创建项目", + "createGitProjectDescription": "创建本地 Git 存储库" } } } 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 2/4] 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( - - - + + + ) From 04f394c17afe6d5355198d36ac3207dac2c68ad2 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 11 Jun 2026 19:47:16 -0700 Subject: [PATCH 3/4] Use outline variant for merge abort buttons (#5236) Change the abort action button variant for merge operations from destructive to outline, aligning it with rebase abort. This uses a quieter, non-destructive treatment for aborting operations. --- .../right-sidebar/CommitArea.test.tsx | 24 ++++++++++++++----- .../right-sidebar/SourceControl.tsx | 16 +++++-------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/CommitArea.test.tsx b/src/renderer/src/components/right-sidebar/CommitArea.test.tsx index 7d377e69391..22f4e73bc8a 100644 --- a/src/renderer/src/components/right-sidebar/CommitArea.test.tsx +++ b/src/renderer/src/components/right-sidebar/CommitArea.test.tsx @@ -384,8 +384,18 @@ describe('ConflictSummaryCard', () => { expect(cherryPickMarkup).not.toContain('Abort rebase') }) - it('renders rebase abort with the quiet outline review-conflicts button treatment', () => { - const markup = renderToStaticMarkup( + it('renders abort actions with the quiet outline review-conflicts button treatment', () => { + const mergeMarkup = renderToStaticMarkup( + + ) + const rebaseMarkup = renderToStaticMarkup( { /> ) - expect(buttonContaining(markup, 'Review conflicts')).toContain('data-variant="outline"') - expect(buttonContaining(markup, 'Abort rebase')).toContain('data-variant="outline"') + expect(buttonContaining(mergeMarkup, 'Review conflicts')).toContain('data-variant="outline"') + expect(buttonContaining(mergeMarkup, 'Abort merge')).toContain('data-variant="outline"') + expect(buttonContaining(rebaseMarkup, 'Review conflicts')).toContain('data-variant="outline"') + expect(buttonContaining(rebaseMarkup, 'Abort rebase')).toContain('data-variant="outline"') }) it('renders the Sparkles icon on the idle Resolve with AI button', () => { @@ -435,7 +447,7 @@ describe('OperationBanner', () => { expect(cherryPickMarkup).not.toContain('Abort rebase') }) - it('keeps rebase abort non-destructive while preserving merge abort styling', () => { + it('renders abort actions with the quiet outline button treatment', () => { const mergeMarkup = renderToStaticMarkup( ) @@ -443,7 +455,7 @@ describe('OperationBanner', () => { ) - expect(buttonContaining(mergeMarkup, 'Abort merge')).toContain('data-variant="destructive"') + expect(buttonContaining(mergeMarkup, 'Abort merge')).toContain('data-variant="outline"') expect(buttonContaining(rebaseMarkup, 'Abort rebase')).toContain('data-variant="outline"') }) }) diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 5f86a1b45a3..83be74d11ca 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -5687,14 +5687,6 @@ function DiffCommentsInlineList({ ) } -function conflictAbortButtonVariant( - conflictOperation: GitConflictOperation -): 'outline' | 'destructive' { - // Why: aborting a rebase is the escape hatch for this state, so it should - // match the quiet outline conflict-review action instead of reading as red. - return conflictOperation === 'rebase' ? 'outline' : 'destructive' -} - export function ConflictSummaryCard({ conflictOperation, unresolvedCount, @@ -5770,7 +5762,9 @@ export function ConflictSummaryCard({ {(conflictOperation === 'merge' || conflictOperation === 'rebase') && onAbortOperation ? ( -
- ) -} - function GHEditSection({ item, repoPath, @@ -4732,18 +4706,6 @@ function GHEditSection({ return null } - const checkIcon = ( - - - - ) - if (layout === 'sidebar') { return (
@@ -4918,39 +4853,15 @@ function GHEditSection({ className="popover-scroll-content scrollbar-sleek w-60 p-1" align="end" > - {repoLabels.error ? ( -
- {repoLabels.error} -
- ) : null} - {!repoLabels.error ? ( -
- {repoLabels.data.map((label) => ( - - ))} -
- ) : null} - 0} - onOpen={() => setLabelPopoverOpen(false)} + setLabelPopoverOpen(false)} /> @@ -5111,39 +5022,15 @@ function GHEditSection({ - {repoLabels.error ? ( -
- {repoLabels.error} -
- ) : null} - {!repoLabels.error ? ( -
- {repoLabels.data.map((label) => ( - - ))} -
- ) : null} - 0} - onOpen={() => setLabelPopoverOpen(false)} + setLabelPopoverOpen(false)} />
@@ -5175,41 +5062,14 @@ function GHEditSection({ - {repoAssignees.error ? ( -
- {repoAssignees.error} -
- ) : ( -
- {repoAssignees.data.map((user) => ( - - ))} -
- )} +
@@ -5276,96 +5136,6 @@ function GHEditSection({ ) } -function GHCommentComposer({ - className, - repoPath, - repoId, - issueNumber, - itemType, - onCommentAdded -}: { - className?: string - repoPath: string - repoId?: string | null - issueNumber: number - itemType: 'issue' | 'pr' - onCommentAdded: (comment: PRComment) => void -}): React.JSX.Element { - const [body, setBody] = useState('') - const [submitting, setSubmitting] = useState(false) - const mountedRef = useMountedRef() - - const handleSubmit = useCallback(async () => { - const trimmed = body.trim() - if (!trimmed) { - return - } - setSubmitting(true) - try { - const result = await addIssueCommentForRepo({ - repoPath, - repoId: repoId ?? undefined, - number: issueNumber, - body: trimmed, - type: itemType - }) - if (!mountedRef.current) { - return - } - if (result.ok) { - setBody('') - // Why: use the comment returned by GitHub so the optimistic row shows - // the real login/avatar immediately instead of waiting for a reopen. - onCommentAdded(result.comment) - } else { - toast.error( - result.error ?? - translate('auto.components.GitHubItemDialog.082515176a', 'Failed to add comment') - ) - } - } catch (err) { - if (mountedRef.current) { - toast.error( - err instanceof Error - ? err.message - : translate('auto.components.GitHubItemDialog.082515176a', 'Failed to add comment') - ) - } - } finally { - if (mountedRef.current) { - setSubmitting(false) - } - } - }, [body, mountedRef, repoPath, repoId, issueNumber, itemType, onCommentAdded]) - - return ( -
- void handleSubmit()} - /> - -
- ) -} - // Why: the dialog doesn't carry the resolved PR-source slug the Tasks view's // list cache carries, so we reach into workItemsCache to recover it. We scope // the lookup to the dialog's own `repoPath` via the public diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx index 13a851dc944..a6b919511c4 100644 --- a/src/renderer/src/components/PullRequestPage.tsx +++ b/src/renderer/src/components/PullRequestPage.tsx @@ -133,6 +133,8 @@ import { useAllWorktrees } from '@/store/selectors' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata' import { useRepoLabelsBySlug, useRepoAssigneesBySlug } from '@/hooks/useGitHubSlugMetadata' +import { GitHubWorkItemLabelPopoverContent } from '@/components/github/GitHubWorkItemLabelPopoverContent' +import { GitHubWorkItemAssigneePopoverContent } from '@/components/github/GitHubWorkItemAssigneePopoverContent' import { getGitHubPRReviewerRows, normalizeGitHubReviewerLogins @@ -200,6 +202,25 @@ function parseOwnerRepoFromItemUrl(url: string): GitHubOwnerRepo | null { } } +function getGitHubRepositoryLabelsUrl(itemUrl: string): string | null { + try { + const parsed = new URL(itemUrl) + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + return null + } + const segments = parsed.pathname.split('/').filter(Boolean) + if (segments.length < 2) { + return null + } + parsed.pathname = `/${segments[0]}/${segments[1]}/labels` + parsed.search = '' + parsed.hash = '' + return parsed.toString() + } catch { + return null + } +} + const MonacoCodeExcerpt = lazy(() => import('@/components/editor/MonacoCodeExcerpt')) export type ItemDialogTab = 'conversation' | 'checks' | 'files' @@ -4948,6 +4969,7 @@ function GHEditSection({ const patchWorkItem = useAppStore((s) => s.patchWorkItem) const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) const { isPending, run } = useImmediateMutation() + const repositoryLabelsUrl = useMemo(() => getGitHubRepositoryLabelsUrl(item.url), [item.url]) // Why: when the dialog opens from a Project view, mutations route through // *BySlug IPCs and we must keep `projectViewCache` in sync alongside // `workItemsCache` — `patchWorkItem` only walks the latter, so without this @@ -5188,18 +5210,6 @@ function GHEditSection({ return null } - const checkIcon = ( - - - - ) - return (
{/* State */} @@ -5269,34 +5279,16 @@ function GHEditSection({ - {repoLabels.error ? ( -
- {repoLabels.error} -
- ) : ( -
- {repoLabels.data.map((label) => ( - - ))} -
- )} + setLabelPopoverOpen(false)} + />
@@ -5327,41 +5319,14 @@ function GHEditSection({ - {repoAssignees.error ? ( -
- {repoAssignees.error} -
- ) : ( -
- {repoAssignees.data.map((user) => ( - - ))} -
- )} +
diff --git a/src/renderer/src/components/github/CloseReasonDropdown.tsx b/src/renderer/src/components/github/CloseReasonDropdown.tsx new file mode 100644 index 00000000000..6002529cbdf --- /dev/null +++ b/src/renderer/src/components/github/CloseReasonDropdown.tsx @@ -0,0 +1,61 @@ +import { Check, ChevronDown } from 'lucide-react' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Button } from '@/components/ui/button' +import { CLOSE_ISSUE_REASONS } from './github-issue-close-reasons' +import type { GitHubIssueCloseReason } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' + +export function CloseReasonDropdown({ + closeReason, + disabled, + onCloseReasonChange +}: { + closeReason: GitHubIssueCloseReason + disabled: boolean + onCloseReasonChange: (reason: GitHubIssueCloseReason) => void +}): React.JSX.Element { + return ( + + + + + + {CLOSE_ISSUE_REASONS.map((option) => ( + onCloseReasonChange(option.reason)}> +
+ {closeReason === option.reason ? ( + + ) : ( + + )} +
+
+ {option.icon} + {option.label} +
+

{option.description}

+
+
+
+ ))} +
+
+ ) +} diff --git a/src/renderer/src/components/github/GitHubIssueCommentComposer.tsx b/src/renderer/src/components/github/GitHubIssueCommentComposer.tsx new file mode 100644 index 00000000000..7de32c72d92 --- /dev/null +++ b/src/renderer/src/components/github/GitHubIssueCommentComposer.tsx @@ -0,0 +1,369 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { LoaderCircle } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { ButtonGroup } from '@/components/ui/button-group' +import { GitHubMarkdownComposer } from '@/components/github/GitHubMarkdownComposer' +import { CLOSE_ISSUE_REASONS } from '@/components/github/github-issue-close-reasons' +import { CloseReasonDropdown } from '@/components/github/CloseReasonDropdown' +import { + addIssueCommentForRepo, + githubAvatarUrl, + runIssueStateUpdate, + type GitHubIssueCommentProjectOrigin +} from '@/components/github/github-issue-comment-helpers' +import { useMountedRef } from '@/hooks/useMountedRef' +import { cn } from '@/lib/utils' +import { useAppStore } from '@/store' +import type { + GitHubIssueCloseReason, + GitHubOwnerRepo, + GitHubViewer, + GitHubWorkItem, + PRComment +} from '../../../../shared/types' +import { translate } from '@/i18n/i18n' + +export function GitHubIssueCommentComposer({ + className, + repoPath, + repoId, + issueNumber, + itemType, + itemState, + itemId, + projectOrigin, + previewGithubRepo, + onCommentAdded, + onStateChange, + onMutated +}: { + className?: string + repoPath: string + repoId?: string | null + issueNumber: number + itemType: 'issue' | 'pr' + itemState?: GitHubWorkItem['state'] + itemId?: string + projectOrigin?: GitHubIssueCommentProjectOrigin + previewGithubRepo?: GitHubOwnerRepo | null + onCommentAdded: (comment: PRComment) => void + onStateChange?: (state: GitHubWorkItem['state']) => void + onMutated?: () => void +}): React.JSX.Element { + const [body, setBody] = useState('') + const [submitting, setSubmitting] = useState(false) + const [statePending, setStatePending] = useState(false) + const [closeReason, setCloseReason] = useState('completed') + const [viewer, setViewer] = useState(null) + const mountedRef = useMountedRef() + const viewerRequestIdRef = useRef(0) + const patchWorkItem = useAppStore((s) => s.patchWorkItem) + const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) + + useEffect(() => { + const requestId = ++viewerRequestIdRef.current + void window.api.gh + .viewer() + .then((nextViewer) => { + if (mountedRef.current && requestId === viewerRequestIdRef.current) { + setViewer(nextViewer) + } + }) + .catch(() => { + if (mountedRef.current && requestId === viewerRequestIdRef.current) { + setViewer(null) + } + }) + return () => { + viewerRequestIdRef.current += 1 + } + }, [mountedRef]) + + const selectedCloseReason = + CLOSE_ISSUE_REASONS.find((option) => option.reason === closeReason) ?? CLOSE_ISSUE_REASONS[0] + + const canMutateIssueState = + itemType === 'issue' && + itemState !== undefined && + itemState !== 'closed' && + Boolean(onStateChange) && + Boolean(repoPath || projectOrigin) + + const canReopenIssue = + itemType === 'issue' && + itemState === 'closed' && + Boolean(onStateChange) && + Boolean(repoPath || projectOrigin) + + const patchProjectRowIfNeeded = useCallback( + (state: GitHubWorkItem['state']) => { + if (!projectOrigin) { + return + } + patchProjectRowContent(projectOrigin.cacheKey, projectOrigin.projectItemId, { state }) + }, + [patchProjectRowContent, projectOrigin] + ) + + const applyStatePatch = useCallback( + (state: GitHubWorkItem['state']) => { + onStateChange?.(state) + if (itemId) { + patchWorkItem(itemId, { state }, repoId ?? undefined) + } + patchProjectRowIfNeeded(state) + }, + [itemId, onStateChange, patchProjectRowIfNeeded, patchWorkItem, repoId] + ) + + const handleSubmit = useCallback(async () => { + const trimmed = body.trim() + if (!trimmed) { + return + } + setSubmitting(true) + try { + const result = await addIssueCommentForRepo({ + repoPath, + repoId: repoId ?? undefined, + number: issueNumber, + body: trimmed, + type: itemType + }) + if (!mountedRef.current) { + return + } + if (result.ok) { + setBody('') + onCommentAdded(result.comment) + } else { + toast.error( + result.error ?? + translate( + 'auto.components.github.GitHubIssueCommentComposer.082515176a', + 'Failed to add comment' + ) + ) + } + } catch (err) { + if (mountedRef.current) { + toast.error( + err instanceof Error + ? err.message + : translate( + 'auto.components.github.GitHubIssueCommentComposer.082515176a', + 'Failed to add comment' + ) + ) + } + } finally { + if (mountedRef.current) { + setSubmitting(false) + } + } + }, [body, issueNumber, itemType, mountedRef, onCommentAdded, repoId, repoPath]) + + const handleCloseIssue = useCallback( + async (reason: GitHubIssueCloseReason = closeReason) => { + if (!canMutateIssueState || statePending) { + return + } + const previousState = itemState ?? 'open' + setStatePending(true) + applyStatePatch('closed') + try { + await runIssueStateUpdate({ + repoPath, + repoId, + projectOrigin, + number: issueNumber, + updates: { state: 'closed', stateReason: reason } + }) + useAppStore.getState().recordFeatureInteraction('github-tasks') + toast.success( + translate('auto.components.github.GitHubIssueCommentComposer.9f88657c4e', 'Issue closed') + ) + onMutated?.() + } catch (err) { + applyStatePatch(previousState) + toast.error( + err instanceof Error + ? err.message + : translate( + 'auto.components.github.GitHubIssueCommentComposer.e9b7cb7d17', + 'Failed to close issue' + ) + ) + } finally { + if (mountedRef.current) { + setStatePending(false) + } + } + }, + [ + applyStatePatch, + canMutateIssueState, + closeReason, + issueNumber, + itemState, + mountedRef, + onMutated, + projectOrigin, + repoId, + repoPath, + statePending + ] + ) + + const handleReopenIssue = useCallback(async () => { + if (!canReopenIssue || statePending) { + return + } + const previousState = itemState ?? 'closed' + setStatePending(true) + applyStatePatch('open') + try { + await runIssueStateUpdate({ + repoPath, + repoId, + projectOrigin, + number: issueNumber, + updates: { state: 'open' } + }) + useAppStore.getState().recordFeatureInteraction('github-tasks') + toast.success( + translate('auto.components.github.GitHubIssueCommentComposer.bd3b4492a0', 'Issue reopened') + ) + onMutated?.() + } catch (err) { + applyStatePatch(previousState) + toast.error( + err instanceof Error + ? err.message + : translate( + 'auto.components.github.GitHubIssueCommentComposer.f2a8c1d903', + 'Failed to reopen issue' + ) + ) + } finally { + if (mountedRef.current) { + setStatePending(false) + } + } + }, [ + applyStatePatch, + canReopenIssue, + issueNumber, + itemState, + mountedRef, + onMutated, + projectOrigin, + repoId, + repoPath, + statePending + ]) + + const avatar = viewer?.login ? ( + {viewer.login} + ) : ( +
+ ) + + return ( +
+
+ {avatar} +
+

+ {translate( + 'auto.components.github.GitHubIssueCommentComposer.a1b2c3d4e5', + 'Add a comment' + )} +

+ void handleSubmit()} + /> +
+ {canMutateIssueState ? ( + + + { + setCloseReason(reason) + void handleCloseIssue(reason) + }} + /> + + ) : null} + {canReopenIssue ? ( + + ) : null} + +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/github/GitHubMarkdownComposer.tsx b/src/renderer/src/components/github/GitHubMarkdownComposer.tsx index 3c64fbe6f33..df4aaf6236c 100644 --- a/src/renderer/src/components/github/GitHubMarkdownComposer.tsx +++ b/src/renderer/src/components/github/GitHubMarkdownComposer.tsx @@ -2,8 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { EditorContent, useEditor } from '@tiptap/react' import type { Editor } from '@tiptap/react' import Placeholder from '@tiptap/extension-placeholder' -import { ImageIcon } from 'lucide-react' -import { toast } from 'sonner' +import { ImageIcon, Paperclip } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { cn } from '@/lib/utils' @@ -17,6 +16,13 @@ import { } from '@/components/editor/RichMarkdownLinkBubble' import { encodeRawMarkdownHtmlForRichEditor } from '@/components/editor/raw-markdown-html' import { normalizeSoftBreaks } from '@/components/editor/rich-markdown-normalize' +import { GitHubMarkdownComposerPreviewPane } from '@/components/github/github-markdown-composer-preview-pane' +import { + GitHubMarkdownComposerTabbar, + type ComposerTab +} from '@/components/github/github-markdown-composer-tabbar' +import { useImageInput } from '@/components/github/use-image-input' +import type { GitHubOwnerRepo } from '../../../../shared/types' import { translate } from '@/i18n/i18n' type GitHubMarkdownComposerProps = { @@ -28,15 +34,8 @@ type GitHubMarkdownComposerProps = { disabled?: boolean autoFocus?: boolean onSubmitShortcut?: () => void -} - -function isHttpImageUrl(value: string): boolean { - try { - const parsed = new URL(value) - return parsed.protocol === 'https:' || parsed.protocol === 'http:' - } catch { - return false - } + layout?: 'stacked' | 'tabbed' + previewGithubRepo?: GitHubOwnerRepo | null } export function GitHubMarkdownComposer({ @@ -47,7 +46,9 @@ export function GitHubMarkdownComposer({ className, disabled = false, autoFocus = false, - onSubmitShortcut + onSubmitShortcut, + layout = 'stacked', + previewGithubRepo = null }: GitHubMarkdownComposerProps): React.JSX.Element { const rootRef = useRef(null) const editorRef = useRef(null) @@ -57,18 +58,25 @@ export function GitHubMarkdownComposer({ const onSubmitShortcutRef = useRef(onSubmitShortcut) const disabledRef = useRef(disabled) const isEditingLinkRef = useRef(false) - const imageInputOpenRef = useRef(false) + const [activeTab, setActiveTab] = useState('write') const [linkBubble, setLinkBubble] = useState(null) const [isEditingLink, setIsEditingLink] = useState(false) - const [imageInputOpen, setImageInputOpen] = useState(false) - const [imageUrl, setImageUrl] = useState('') - const imageInputRef = useRef(null) + const isTabbed = layout === 'tabbed' + + const { + imageUrl, + imageInputOpen, + imageInputRef, + openImagePicker, + setImageUrl, + setImageInputOpen, + insertImageUrl + } = useImageInput(editorRef, disabledRef, () => setActiveTab('write')) onChangeRef.current = onChange onSubmitShortcutRef.current = onSubmitShortcut disabledRef.current = disabled isEditingLinkRef.current = isEditingLink - imageInputOpenRef.current = imageInputOpen const extensions = useMemo( () => [ @@ -125,7 +133,7 @@ export function GitHubMarkdownComposer({ openLinkEditor() return true } - if (event.key === 'Escape' && imageInputOpenRef.current) { + if (event.key === 'Escape' && imageInputOpen) { event.preventDefault() event.stopPropagation() setImageInputOpen(false) @@ -183,6 +191,23 @@ export function GitHubMarkdownComposer({ if (!editor) { return } + // Why: parent clears to '' after submit; always reset the editor so stale + // draft text never survives a successful comment post. + if (!value.trim()) { + if (editor.getMarkdown().trim()) { + applyingExternalValueRef.current = true + try { + editor.commands.clearContent(true) + normalizeSoftBreaks(editor) + lastSyncedMarkdownRef.current = '' + } finally { + applyingExternalValueRef.current = false + } + } else { + lastSyncedMarkdownRef.current = '' + } + return + } if (value === lastSyncedMarkdownRef.current || value === editor.getMarkdown()) { lastSyncedMarkdownRef.current = value return @@ -200,12 +225,6 @@ export function GitHubMarkdownComposer({ } }, [editor, value]) - useEffect(() => { - if (imageInputOpen) { - requestAnimationFrame(() => imageInputRef.current?.focus()) - } - }, [imageInputOpen]) - const handleLinkSave = useCallback((href: string) => { const editor = editorRef.current if (!editor) { @@ -249,92 +268,107 @@ export function GitHubMarkdownComposer({ } }, [linkBubble?.href]) - const insertImageUrl = useCallback(() => { - const editor = editorRef.current - const trimmed = imageUrl.trim() - if (!editor || !trimmed) { - return - } - if (!isHttpImageUrl(trimmed)) { - toast.error( - translate( - 'auto.components.github.GitHubMarkdownComposer.ec6310b731', - 'Use an http:// or https:// image URL.' - ) - ) - return - } - editor - .chain() - .focus() - .insertContent({ type: 'image', attrs: { src: trimmed } }) - .run() - setImageUrl('') - setImageInputOpen(false) - }, [imageUrl]) + const toolbar = ( + + ) + + const imageInputRow = imageInputOpen ? ( +
{ + event.preventDefault() + insertImageUrl() + }} + > + + setImageUrl(event.target.value)} + onKeyDown={(event) => { + if (isScreenSubmitShortcut(event)) { + event.preventDefault() + event.stopPropagation() + insertImageUrl() + return + } + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + setImageInputOpen(false) + } + }} + placeholder={translate( + 'auto.components.github.GitHubMarkdownComposer.f24783f470', + 'https://...' + )} + disabled={disabled} + className="h-8 min-w-0 text-xs" + /> + + + + ) : null + + const editorPane = ( +
+ +
+ ) + + const previewPane = ( + + ) + + const attachmentFooter = isTabbed ? ( + + ) : null return (
- { - if (!disabledRef.current) { - setImageInputOpen(true) - } - }} - /> - {imageInputOpen ? ( -
{ - event.preventDefault() - insertImageUrl() - }} - > - - setImageUrl(event.target.value)} - onKeyDown={(event) => { - if (isScreenSubmitShortcut(event)) { - event.preventDefault() - event.stopPropagation() - insertImageUrl() - return - } - if (event.key === 'Escape') { - event.preventDefault() - event.stopPropagation() - setImageInputOpen(false) - } - }} - placeholder={translate( - 'auto.components.github.GitHubMarkdownComposer.f24783f470', - 'https://...' - )} - disabled={disabled} - className="h-8 min-w-0 text-xs" - /> - - - - ) : null} -
- -
+ {isTabbed ? ( + + {toolbar} + + ) : ( + toolbar + )} + {imageInputRow} + {isTabbed ? (activeTab === 'write' ? editorPane : previewPane) : editorPane} + {attachmentFooter} {linkBubble ? ( + + +) + +export function GitHubWorkItemAssigneePopoverContent({ + open, + assignees, + selectedLogins, + error, + loading, + onToggleAssignee +}: { + open: boolean + assignees: readonly GitHubAssignableUser[] + selectedLogins: readonly string[] + error: string | null | undefined + loading?: boolean + onToggleAssignee: (login: string) => void +}): React.JSX.Element { + const [query, setQuery] = useState('') + const filteredAssignees = useMemo( + () => filterGitHubWorkItemAssignees(assignees, query), + [assignees, query] + ) + const selectedSet = useMemo(() => new Set(selectedLogins), [selectedLogins]) + + useEffect(() => { + if (!open) { + setQuery('') + } + }, [open]) + + if (error) { + return
{error}
+ } + + const emptyText = loading + ? translate( + 'auto.components.github.GitHubWorkItemAssigneePopoverContent.cddd9b04a7', + 'Loading assignees' + ) + : translate( + 'auto.components.github.GitHubWorkItemAssigneePopoverContent.a00830d3f7', + 'No users' + ) + + return ( + + + + + {emptyText} + + {filteredAssignees.map((user) => { + const isSelected = selectedSet.has(user.login) + return ( + onToggleAssignee(user.login)} + className="flex items-center gap-2 rounded-sm px-2 py-1.5 text-[12px]" + > + + {isSelected ? assigneeCheckIcon : null} + + + {user.login} + {user.name ? ( + + {user.name} + + ) : null} + + + ) + })} + + + ) +} diff --git a/src/renderer/src/components/github/GitHubWorkItemLabelPopoverContent.tsx b/src/renderer/src/components/github/GitHubWorkItemLabelPopoverContent.tsx new file mode 100644 index 00000000000..ff0612e86bb --- /dev/null +++ b/src/renderer/src/components/github/GitHubWorkItemLabelPopoverContent.tsx @@ -0,0 +1,152 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { ExternalLink, Settings } from 'lucide-react' +import { + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList +} from '@/components/ui/command' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { filterGitHubWorkItemLabels } from './github-work-item-label-filter' + +const labelCheckIcon = ( + + + +) + +function GitHubLabelsSettingsLink({ + url, + separated, + onOpen +}: { + url: string | null + separated?: boolean + onOpen?: () => void +}): React.JSX.Element | null { + if (!url) { + return null + } + + return ( +
+ +
+ ) +} + +export function GitHubWorkItemLabelPopoverContent({ + open, + labels, + selectedLabels, + error, + loading, + repositoryLabelsUrl, + onToggleLabel, + onOpenSettingsLink +}: { + open: boolean + labels: readonly string[] + selectedLabels: readonly string[] + error: string | null | undefined + loading?: boolean + repositoryLabelsUrl?: string | null + onToggleLabel: (label: string) => void + onOpenSettingsLink?: () => void +}): React.JSX.Element { + const [query, setQuery] = useState('') + const filteredLabels = useMemo(() => filterGitHubWorkItemLabels(labels, query), [labels, query]) + const selectedSet = useMemo(() => new Set(selectedLabels), [selectedLabels]) + + useEffect(() => { + if (!open) { + setQuery('') + } + }, [open]) + + if (error) { + return
{error}
+ } + + const emptyText = loading + ? translate( + 'auto.components.github.GitHubWorkItemLabelPopoverContent.cddd9b04a7', + 'Loading labels' + ) + : translate('auto.components.github.GitHubWorkItemLabelPopoverContent.de26e2eb06', 'No labels') + + return ( + <> + + + + + {emptyText} + + {filteredLabels.map((label) => { + const isSelected = selectedSet.has(label) + return ( + onToggleLabel(label)} + className="flex items-center gap-2 rounded-sm px-2 py-1.5 text-[12px]" + > + + {isSelected ? labelCheckIcon : null} + + {label} + + ) + })} + + + 0} + onOpen={onOpenSettingsLink} + /> + + ) +} diff --git a/src/renderer/src/components/github/github-issue-close-reasons.tsx b/src/renderer/src/components/github/github-issue-close-reasons.tsx new file mode 100644 index 00000000000..c9034a1cbb0 --- /dev/null +++ b/src/renderer/src/components/github/github-issue-close-reasons.tsx @@ -0,0 +1,49 @@ +import { Ban, CheckCircle2, Copy } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import type { GitHubIssueCloseReason } from '../../../../shared/types' + +export type CloseIssueReasonOption = { + reason: GitHubIssueCloseReason + label: string + description: string + icon: React.JSX.Element +} + +export const CLOSE_ISSUE_REASONS: CloseIssueReasonOption[] = [ + { + reason: 'completed', + label: translate( + 'auto.components.github.githubIssueCloseReasons.completed.label', + 'Close as completed' + ), + description: translate( + 'auto.components.github.githubIssueCloseReasons.completed.description', + 'Done, closed, fixed, resolved' + ), + icon: + }, + { + reason: 'not_planned', + label: translate( + 'auto.components.github.githubIssueCloseReasons.notPlanned.label', + 'Close as not planned' + ), + description: translate( + 'auto.components.github.githubIssueCloseReasons.notPlanned.description', + "Won't fix, can't repro, stale" + ), + icon: + }, + { + reason: 'duplicate', + label: translate( + 'auto.components.github.githubIssueCloseReasons.duplicate.label', + 'Close as duplicate' + ), + description: translate( + 'auto.components.github.githubIssueCloseReasons.duplicate.description', + 'Duplicate of another issue' + ), + icon: + } +] diff --git a/src/renderer/src/components/github/github-issue-comment-helpers.ts b/src/renderer/src/components/github/github-issue-comment-helpers.ts new file mode 100644 index 00000000000..6bb52ea9b60 --- /dev/null +++ b/src/renderer/src/components/github/github-issue-comment-helpers.ts @@ -0,0 +1,74 @@ +import { useAppStore } from '@/store' +import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import type { GitHubIssueCloseReason } from '../../../../shared/types' + +export type GitHubIssueCommentProjectOrigin = { + owner: string + repo: string + cacheKey: string + projectItemId: string +} + +export async function runIssueStateUpdate(args: { + repoPath: string + repoId?: string | null + projectOrigin: GitHubIssueCommentProjectOrigin | undefined + number: number + updates: { + state: 'open' | 'closed' + stateReason?: GitHubIssueCloseReason + duplicateOf?: number + } +}): Promise { + if (args.projectOrigin) { + const target = getActiveRuntimeTarget(useAppStore.getState().settings) + const updateArgs = { + owner: args.projectOrigin.owner, + repo: args.projectOrigin.repo, + number: args.number, + updates: args.updates + } + const res = + target.kind === 'environment' + ? await callRuntimeRpc>>( + target, + 'github.project.updateIssueBySlug', + updateArgs, + { timeoutMs: 30_000 } + ) + : await window.api.gh.updateIssueBySlug(updateArgs) + if (!res.ok) { + throw new Error(res.error.message) + } + return + } + const res = await window.api.gh.updateIssue({ + repoPath: args.repoPath, + repoId: args.repoId ?? undefined, + number: args.number, + updates: args.updates + }) + if (!res.ok) { + throw new Error(res.error) + } +} + +export async function addIssueCommentForRepo(args: { + repoId?: string + repoPath: string + number: number + body: string + type?: 'issue' | 'pr' +}): Promise>> { + return window.api.gh.addIssueComment({ + repoPath: args.repoPath, + repoId: args.repoId, + number: args.number, + body: args.body, + type: args.type + }) +} + +export function githubAvatarUrl(login: string): string { + return `https://github.com/${encodeURIComponent(login)}.png?size=64` +} diff --git a/src/renderer/src/components/github/github-markdown-composer-preview-pane.tsx b/src/renderer/src/components/github/github-markdown-composer-preview-pane.tsx new file mode 100644 index 00000000000..06739d438b7 --- /dev/null +++ b/src/renderer/src/components/github/github-markdown-composer-preview-pane.tsx @@ -0,0 +1,44 @@ +import CommentMarkdown from '@/components/sidebar/CommentMarkdown' +import type { GitHubOwnerRepo } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' + +export function isHttpImageUrl(value: string): boolean { + try { + const parsed = new URL(value) + return parsed.protocol === 'https:' || parsed.protocol === 'http:' + } catch { + return false + } +} + +export function GitHubMarkdownComposerPreviewPane({ + value, + minHeightClassName, + previewGithubRepo +}: { + value: string + minHeightClassName: string + previewGithubRepo: GitHubOwnerRepo | null +}): React.JSX.Element { + return ( +
+ {value.trim() ? ( + + ) : ( +

+ {translate( + 'auto.components.github.GitHubMarkdownComposer.8f1c2d4e6a', + 'Nothing to preview' + )} +

+ )} +
+ ) +} diff --git a/src/renderer/src/components/github/github-markdown-composer-tabbar.tsx b/src/renderer/src/components/github/github-markdown-composer-tabbar.tsx new file mode 100644 index 00000000000..2436cb970a7 --- /dev/null +++ b/src/renderer/src/components/github/github-markdown-composer-tabbar.tsx @@ -0,0 +1,43 @@ +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import type { ReactNode } from 'react' + +export type ComposerTab = 'write' | 'preview' + +export function GitHubMarkdownComposerTabbar({ + activeTab, + onTabChange, + children +}: { + activeTab: ComposerTab + onTabChange: (tab: ComposerTab) => void + children: ReactNode +}): React.JSX.Element { + return ( +
+
+ + +
+ {activeTab === 'write' ? ( +
{children}
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/github/github-work-item-assignee-filter.test.ts b/src/renderer/src/components/github/github-work-item-assignee-filter.test.ts new file mode 100644 index 00000000000..eb3e340729f --- /dev/null +++ b/src/renderer/src/components/github/github-work-item-assignee-filter.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { filterGitHubWorkItemAssignees } from './github-work-item-assignee-filter' + +describe('filterGitHubWorkItemAssignees', () => { + const assignees = [ + { login: 'alice', name: 'Alice Smith', avatarUrl: 'https://example.com/alice.png' }, + { login: 'bob', name: 'Bob Jones', avatarUrl: 'https://example.com/bob.png' }, + { login: 'carol', name: null, avatarUrl: 'https://example.com/carol.png' } + ] + + it('returns all assignees when the query is empty', () => { + expect(filterGitHubWorkItemAssignees(assignees, '')).toEqual(assignees) + expect(filterGitHubWorkItemAssignees(assignees, ' ')).toEqual(assignees) + }) + + it('matches logins case-insensitively', () => { + expect(filterGitHubWorkItemAssignees(assignees, 'BOB')).toEqual([assignees[1]]) + }) + + it('matches display names case-insensitively', () => { + expect(filterGitHubWorkItemAssignees(assignees, 'smith')).toEqual([assignees[0]]) + expect(filterGitHubWorkItemAssignees(assignees, 'jones')).toEqual([assignees[1]]) + }) +}) diff --git a/src/renderer/src/components/github/github-work-item-assignee-filter.ts b/src/renderer/src/components/github/github-work-item-assignee-filter.ts new file mode 100644 index 00000000000..6682321f24e --- /dev/null +++ b/src/renderer/src/components/github/github-work-item-assignee-filter.ts @@ -0,0 +1,16 @@ +import type { GitHubAssignableUser } from '../../../../shared/types' + +export function filterGitHubWorkItemAssignees( + assignees: readonly GitHubAssignableUser[], + query: string +): GitHubAssignableUser[] { + const normalizedQuery = query.trim().toLowerCase() + if (!normalizedQuery) { + return [...assignees] + } + return assignees.filter( + (user) => + user.login.toLowerCase().includes(normalizedQuery) || + (user.name ?? '').toLowerCase().includes(normalizedQuery) + ) +} diff --git a/src/renderer/src/components/github/github-work-item-label-filter.test.ts b/src/renderer/src/components/github/github-work-item-label-filter.test.ts new file mode 100644 index 00000000000..7f1adad477c --- /dev/null +++ b/src/renderer/src/components/github/github-work-item-label-filter.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { filterGitHubWorkItemLabels } from './github-work-item-label-filter' + +describe('filterGitHubWorkItemLabels', () => { + const labels = ['agent-workflow', 'bug', 'documentation', 'duplicate'] + + it('returns all labels when the query is empty', () => { + expect(filterGitHubWorkItemLabels(labels, '')).toEqual(labels) + expect(filterGitHubWorkItemLabels(labels, ' ')).toEqual(labels) + }) + + it('matches labels case-insensitively', () => { + expect(filterGitHubWorkItemLabels(labels, 'BUG')).toEqual(['bug']) + expect(filterGitHubWorkItemLabels(labels, 'Doc')).toEqual(['documentation']) + }) + + it('matches partial label names', () => { + expect(filterGitHubWorkItemLabels(labels, 'agent')).toEqual(['agent-workflow']) + expect(filterGitHubWorkItemLabels(labels, 'dup')).toEqual(['duplicate']) + }) +}) diff --git a/src/renderer/src/components/github/github-work-item-label-filter.ts b/src/renderer/src/components/github/github-work-item-label-filter.ts new file mode 100644 index 00000000000..65f6b27b60c --- /dev/null +++ b/src/renderer/src/components/github/github-work-item-label-filter.ts @@ -0,0 +1,7 @@ +export function filterGitHubWorkItemLabels(labels: readonly string[], query: string): string[] { + const normalizedQuery = query.trim().toLowerCase() + if (!normalizedQuery) { + return [...labels] + } + return labels.filter((label) => label.toLowerCase().includes(normalizedQuery)) +} diff --git a/src/renderer/src/components/github/use-image-input.ts b/src/renderer/src/components/github/use-image-input.ts new file mode 100644 index 00000000000..ab6d5e11a15 --- /dev/null +++ b/src/renderer/src/components/github/use-image-input.ts @@ -0,0 +1,70 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { toast } from 'sonner' +import type { Editor } from '@tiptap/react' +import { isHttpImageUrl } from './github-markdown-composer-preview-pane' +import { translate } from '@/i18n/i18n' + +export function useImageInput( + editorRef: React.MutableRefObject, + disabledRef: React.MutableRefObject, + onOpen?: () => void +): { + imageUrl: string + imageInputOpen: boolean + imageInputRef: React.RefObject + openImagePicker: () => void + setImageUrl: (value: string) => void + setImageInputOpen: (open: boolean) => void + insertImageUrl: () => void +} { + const [imageInputOpen, setImageInputOpen] = useState(false) + const [imageUrl, setImageUrl] = useState('') + const imageInputRef = useRef(null) + + useEffect(() => { + if (imageInputOpen) { + requestAnimationFrame(() => imageInputRef.current?.focus()) + } + }, [imageInputOpen]) + + const insertImageUrl = useCallback(() => { + const editor = editorRef.current + const trimmed = imageUrl.trim() + if (!editor || !trimmed) { + return + } + if (!isHttpImageUrl(trimmed)) { + toast.error( + translate( + 'auto.components.github.GitHubMarkdownComposer.ec6310b731', + 'Use an http:// or https:// image URL.' + ) + ) + return + } + editor + .chain() + .focus() + .insertContent({ type: 'image', attrs: { src: trimmed } }) + .run() + setImageUrl('') + setImageInputOpen(false) + }, [imageUrl, editorRef]) + + const openImagePicker = useCallback(() => { + if (!disabledRef.current) { + setImageInputOpen(true) + onOpen?.() + } + }, [disabledRef, onOpen]) + + return { + imageUrl, + imageInputOpen, + imageInputRef, + openImagePicker, + setImageUrl, + setImageInputOpen, + insertImageUrl + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 59d81ba2579..4d26adf5305 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -463,6 +463,35 @@ "7d732521ec": "Comment" } } + }, + "folderWorkspacePathStatus": { + "title": { + "missing": "Folder not found", + "notDirectory": "Path is not a folder", + "ambiguousConnection": "Cannot determine connection", + "unavailable": "Cannot check folder" + }, + "description": { + "missing": "Orca cannot find {{path}}. Remove and re-import this folder workspace.", + "notDirectory": "{{path}} exists, but it is not a folder.", + "ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.", + "unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again." + }, + "createError": { + "title": { + "missing": "Folder not found", + "notDirectory": "Path is not a folder", + "ambiguousConnection": "Cannot determine connection", + "unavailable": "Cannot check folder", + "generic": "Failed to create folder workspace" + }, + "description": { + "missing": "Orca cannot find {{path}}. Remove and re-import the folder.", + "notDirectory": "{{path}} exists, but it is not a folder.", + "ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.", + "unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again." + } + } } }, "hooks": { @@ -1775,7 +1804,11 @@ "015b4e607d": "Cancel", "e3bd59143c": "Insert", "f24783f470": "https://...", - "ec6310b731": "Use an http:// or https:// image URL." + "ec6310b731": "Use an http:// or https:// image URL.", + "b7e4a1c902": "Paste, drop, or click to add files", + "8f1c2d4e6a": "Nothing to preview", + "c91f0a2b14": "Write", + "d82b1e3f05": "Preview" }, "IssueSourceSelector": { "d6aeb2012b": "Showing issues from", @@ -1849,6 +1882,47 @@ } } } + }, + "CloseReasonDropdown": { + "e1f2a3b4c5": "Choose close reason" + }, + "GitHubIssueCommentComposer": { + "082515176a": "Failed to add comment", + "9f88657c4e": "Issue closed", + "e9b7cb7d17": "Failed to close issue", + "bd3b4492a0": "Issue reopened", + "f2a8c1d903": "Failed to reopen issue", + "a1b2c3d4e5": "Add a comment", + "c5c117270e": "Add your comment here, be kind", + "f6a7b8c9d0": "Close issue", + "b1c2d3e4f5": "Reopen issue", + "0a73f59e85": "Send comment", + "bf43425540": "Comment" + }, + "GitHubWorkItemAssigneePopoverContent": { + "cddd9b04a7": "Loading assignees", + "a00830d3f7": "No users", + "4f8b6f2c1d": "Filter assignees..." + }, + "GitHubWorkItemLabelPopoverContent": { + "2aa9acdf34": "Edit labels on GitHub", + "cddd9b04a7": "Loading labels", + "de26e2eb06": "No labels", + "8b0d52ee3a": "Filter labels..." + }, + "githubIssueCloseReasons": { + "completed": { + "label": "Close as completed", + "description": "Done, closed, fixed, resolved" + }, + "notPlanned": { + "label": "Close as not planned", + "description": "Won't fix, can't repro, stale" + }, + "duplicate": { + "label": "Close as duplicate", + "description": "Duplicate of another issue" + } } }, "linear": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 65cbbcee527..f365b395192 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -463,6 +463,35 @@ "7d732521ec": "Comentario" } } + }, + "folderWorkspacePathStatus": { + "title": { + "missing": "Folder not found", + "notDirectory": "Path is not a folder", + "ambiguousConnection": "Cannot determine connection", + "unavailable": "Cannot check folder" + }, + "description": { + "missing": "Orca cannot find {{path}}. Remove and re-import this folder workspace.", + "notDirectory": "{{path}} exists, but it is not a folder.", + "ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.", + "unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again." + }, + "createError": { + "title": { + "missing": "Folder not found", + "notDirectory": "Path is not a folder", + "ambiguousConnection": "Cannot determine connection", + "unavailable": "Cannot check folder", + "generic": "Failed to create folder workspace" + }, + "description": { + "missing": "Orca cannot find {{path}}. Remove and re-import the folder.", + "notDirectory": "{{path}} exists, but it is not a folder.", + "ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.", + "unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again." + } + } } }, "hooks": { @@ -1775,7 +1804,11 @@ "015b4e607d": "Cancelar", "e3bd59143c": "Insertar", "f24783f470": "https://...", - "ec6310b731": "Utilice una URL de imagen http:// o https://." + "ec6310b731": "Utilice una URL de imagen http:// o https://.", + "b7e4a1c902": "Paste, drop, or click to add files", + "8f1c2d4e6a": "Nothing to preview", + "c91f0a2b14": "Write", + "d82b1e3f05": "Preview" }, "IssueSourceSelector": { "d6aeb2012b": "Mostrando problemas de", @@ -1849,6 +1882,47 @@ } } } + }, + "CloseReasonDropdown": { + "e1f2a3b4c5": "Choose close reason" + }, + "GitHubIssueCommentComposer": { + "082515176a": "Failed to add comment", + "9f88657c4e": "Issue closed", + "e9b7cb7d17": "Failed to close issue", + "bd3b4492a0": "Issue reopened", + "f2a8c1d903": "Failed to reopen issue", + "a1b2c3d4e5": "Add a comment", + "c5c117270e": "Add your comment here, be kind", + "f6a7b8c9d0": "Close issue", + "b1c2d3e4f5": "Reopen issue", + "0a73f59e85": "Send comment", + "bf43425540": "Comment" + }, + "GitHubWorkItemAssigneePopoverContent": { + "cddd9b04a7": "Loading assignees", + "a00830d3f7": "No users", + "4f8b6f2c1d": "Filter assignees..." + }, + "GitHubWorkItemLabelPopoverContent": { + "2aa9acdf34": "Edit labels on GitHub", + "cddd9b04a7": "Loading labels", + "de26e2eb06": "No labels", + "8b0d52ee3a": "Filter labels..." + }, + "githubIssueCloseReasons": { + "completed": { + "label": "Close as completed", + "description": "Done, closed, fixed, resolved" + }, + "notPlanned": { + "label": "Close as not planned", + "description": "Won't fix, can't repro, stale" + }, + "duplicate": { + "label": "Close as duplicate", + "description": "Duplicate of another issue" + } } }, "linear": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 888ce576bb0..af80ad64554 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -463,6 +463,35 @@ "7d732521ec": "コメント" } } + }, + "folderWorkspacePathStatus": { + "title": { + "missing": "Folder not found", + "notDirectory": "Path is not a folder", + "ambiguousConnection": "Cannot determine connection", + "unavailable": "Cannot check folder" + }, + "description": { + "missing": "Orca cannot find {{path}}. Remove and re-import this folder workspace.", + "notDirectory": "{{path}} exists, but it is not a folder.", + "ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.", + "unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again." + }, + "createError": { + "title": { + "missing": "Folder not found", + "notDirectory": "Path is not a folder", + "ambiguousConnection": "Cannot determine connection", + "unavailable": "Cannot check folder", + "generic": "Failed to create folder workspace" + }, + "description": { + "missing": "Orca cannot find {{path}}. Remove and re-import the folder.", + "notDirectory": "{{path}} exists, but it is not a folder.", + "ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.", + "unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again." + } + } } }, "hooks": { @@ -1775,7 +1804,11 @@ "015b4e607d": "キャンセル", "e3bd59143c": "入れる", "f24783f470": "https://...", - "ec6310b731": "http:// または https:// 画像 URL を使用します。" + "ec6310b731": "http:// または https:// 画像 URL を使用します。", + "b7e4a1c902": "Paste, drop, or click to add files", + "8f1c2d4e6a": "Nothing to preview", + "c91f0a2b14": "Write", + "d82b1e3f05": "Preview" }, "IssueSourceSelector": { "d6aeb2012b": "からのイシューを表示しています", @@ -1849,6 +1882,47 @@ } } } + }, + "CloseReasonDropdown": { + "e1f2a3b4c5": "Choose close reason" + }, + "GitHubIssueCommentComposer": { + "082515176a": "Failed to add comment", + "9f88657c4e": "Issue closed", + "e9b7cb7d17": "Failed to close issue", + "bd3b4492a0": "Issue reopened", + "f2a8c1d903": "Failed to reopen issue", + "a1b2c3d4e5": "Add a comment", + "c5c117270e": "Add your comment here, be kind", + "f6a7b8c9d0": "Close issue", + "b1c2d3e4f5": "Reopen issue", + "0a73f59e85": "Send comment", + "bf43425540": "Comment" + }, + "GitHubWorkItemAssigneePopoverContent": { + "cddd9b04a7": "Loading assignees", + "a00830d3f7": "No users", + "4f8b6f2c1d": "Filter assignees..." + }, + "GitHubWorkItemLabelPopoverContent": { + "2aa9acdf34": "Edit labels on GitHub", + "cddd9b04a7": "Loading labels", + "de26e2eb06": "No labels", + "8b0d52ee3a": "Filter labels..." + }, + "githubIssueCloseReasons": { + "completed": { + "label": "Close as completed", + "description": "Done, closed, fixed, resolved" + }, + "notPlanned": { + "label": "Close as not planned", + "description": "Won't fix, can't repro, stale" + }, + "duplicate": { + "label": "Close as duplicate", + "description": "Duplicate of another issue" + } } }, "linear": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 8b34556d89c..378cbbd42d8 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -463,6 +463,35 @@ "7d732521ec": "댓글" } } + }, + "folderWorkspacePathStatus": { + "title": { + "missing": "Folder not found", + "notDirectory": "Path is not a folder", + "ambiguousConnection": "Cannot determine connection", + "unavailable": "Cannot check folder" + }, + "description": { + "missing": "Orca cannot find {{path}}. Remove and re-import this folder workspace.", + "notDirectory": "{{path}} exists, but it is not a folder.", + "ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.", + "unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again." + }, + "createError": { + "title": { + "missing": "Folder not found", + "notDirectory": "Path is not a folder", + "ambiguousConnection": "Cannot determine connection", + "unavailable": "Cannot check folder", + "generic": "Failed to create folder workspace" + }, + "description": { + "missing": "Orca cannot find {{path}}. Remove and re-import the folder.", + "notDirectory": "{{path}} exists, but it is not a folder.", + "ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.", + "unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again." + } + } } }, "hooks": { @@ -1775,7 +1804,11 @@ "015b4e607d": "취소", "e3bd59143c": "끼워 넣다", "f24783f470": "https://...", - "ec6310b731": "http:// 또는 https:// 이미지 URL을 사용하세요." + "ec6310b731": "http:// 또는 https:// 이미지 URL을 사용하세요.", + "b7e4a1c902": "Paste, drop, or click to add files", + "8f1c2d4e6a": "Nothing to preview", + "c91f0a2b14": "Write", + "d82b1e3f05": "Preview" }, "IssueSourceSelector": { "d6aeb2012b": "다음에서 이슈 표시", @@ -1849,6 +1882,47 @@ } } } + }, + "CloseReasonDropdown": { + "e1f2a3b4c5": "Choose close reason" + }, + "GitHubIssueCommentComposer": { + "082515176a": "Failed to add comment", + "9f88657c4e": "Issue closed", + "e9b7cb7d17": "Failed to close issue", + "bd3b4492a0": "Issue reopened", + "f2a8c1d903": "Failed to reopen issue", + "a1b2c3d4e5": "Add a comment", + "c5c117270e": "Add your comment here, be kind", + "f6a7b8c9d0": "Close issue", + "b1c2d3e4f5": "Reopen issue", + "0a73f59e85": "Send comment", + "bf43425540": "Comment" + }, + "GitHubWorkItemAssigneePopoverContent": { + "cddd9b04a7": "Loading assignees", + "a00830d3f7": "No users", + "4f8b6f2c1d": "Filter assignees..." + }, + "GitHubWorkItemLabelPopoverContent": { + "2aa9acdf34": "Edit labels on GitHub", + "cddd9b04a7": "Loading labels", + "de26e2eb06": "No labels", + "8b0d52ee3a": "Filter labels..." + }, + "githubIssueCloseReasons": { + "completed": { + "label": "Close as completed", + "description": "Done, closed, fixed, resolved" + }, + "notPlanned": { + "label": "Close as not planned", + "description": "Won't fix, can't repro, stale" + }, + "duplicate": { + "label": "Close as duplicate", + "description": "Duplicate of another issue" + } } }, "linear": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 50187293a2e..93c92b05708 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -463,6 +463,35 @@ "7d732521ec": "评论" } } + }, + "folderWorkspacePathStatus": { + "title": { + "missing": "Folder not found", + "notDirectory": "Path is not a folder", + "ambiguousConnection": "Cannot determine connection", + "unavailable": "Cannot check folder" + }, + "description": { + "missing": "Orca cannot find {{path}}. Remove and re-import this folder workspace.", + "notDirectory": "{{path}} exists, but it is not a folder.", + "ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.", + "unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again." + }, + "createError": { + "title": { + "missing": "Folder not found", + "notDirectory": "Path is not a folder", + "ambiguousConnection": "Cannot determine connection", + "unavailable": "Cannot check folder", + "generic": "Failed to create folder workspace" + }, + "description": { + "missing": "Orca cannot find {{path}}. Remove and re-import the folder.", + "notDirectory": "{{path}} exists, but it is not a folder.", + "ambiguousConnection": "Orca cannot tell which SSH connection owns this folder scope.", + "unavailable": "Orca cannot verify this folder right now. Check the runtime or SSH connection and try again." + } + } } }, "hooks": { @@ -1775,7 +1804,11 @@ "015b4e607d": "取消", "e3bd59143c": "插入", "f24783f470": "https://...", - "ec6310b731": "使用 http:// 或 https:// 图片 URL。" + "ec6310b731": "使用 http:// 或 https:// 图片 URL。", + "b7e4a1c902": "Paste, drop, or click to add files", + "8f1c2d4e6a": "Nothing to preview", + "c91f0a2b14": "Write", + "d82b1e3f05": "Preview" }, "IssueSourceSelector": { "d6aeb2012b": "显示议题来自", @@ -1849,6 +1882,47 @@ } } } + }, + "CloseReasonDropdown": { + "e1f2a3b4c5": "Choose close reason" + }, + "GitHubIssueCommentComposer": { + "082515176a": "Failed to add comment", + "9f88657c4e": "Issue closed", + "e9b7cb7d17": "Failed to close issue", + "bd3b4492a0": "Issue reopened", + "f2a8c1d903": "Failed to reopen issue", + "a1b2c3d4e5": "Add a comment", + "c5c117270e": "Add your comment here, be kind", + "f6a7b8c9d0": "Close issue", + "b1c2d3e4f5": "Reopen issue", + "0a73f59e85": "Send comment", + "bf43425540": "Comment" + }, + "GitHubWorkItemAssigneePopoverContent": { + "cddd9b04a7": "Loading assignees", + "a00830d3f7": "No users", + "4f8b6f2c1d": "Filter assignees..." + }, + "GitHubWorkItemLabelPopoverContent": { + "2aa9acdf34": "Edit labels on GitHub", + "cddd9b04a7": "Loading labels", + "de26e2eb06": "No labels", + "8b0d52ee3a": "Filter labels..." + }, + "githubIssueCloseReasons": { + "completed": { + "label": "Close as completed", + "description": "Done, closed, fixed, resolved" + }, + "notPlanned": { + "label": "Close as not planned", + "description": "Won't fix, can't repro, stale" + }, + "duplicate": { + "label": "Close as duplicate", + "description": "Duplicate of another issue" + } } }, "linear": { diff --git a/src/renderer/src/lib/folder-workspace-path-status.ts b/src/renderer/src/lib/folder-workspace-path-status.ts index 0a2eda79f9f..51f2807d6cc 100644 --- a/src/renderer/src/lib/folder-workspace-path-status.ts +++ b/src/renderer/src/lib/folder-workspace-path-status.ts @@ -1,3 +1,4 @@ +import { translate } from '@/i18n/i18n' import type { FolderWorkspacePathStatus } from '../../../shared/folder-workspace-path-status' import { blocksFolderWorkspaceActivation } from '../../../shared/folder-workspace-path-status' @@ -9,14 +10,23 @@ export function getFolderWorkspacePathStatusTitle( } switch (status.reason) { case 'missing': - return 'Folder not found' + return translate('auto.lib.folderWorkspacePathStatus.title.missing', 'Folder not found') case 'not-directory': - return 'Path is not a folder' + return translate( + 'auto.lib.folderWorkspacePathStatus.title.notDirectory', + 'Path is not a folder' + ) case 'ambiguous-connection': - return 'Cannot determine connection' + return translate( + 'auto.lib.folderWorkspacePathStatus.title.ambiguousConnection', + 'Cannot determine connection' + ) + case undefined: case 'unavailable': - default: - return 'Cannot check folder' + return translate( + 'auto.lib.folderWorkspacePathStatus.title.unavailable', + 'Cannot check folder' + ) } } @@ -28,14 +38,28 @@ export function getFolderWorkspacePathStatusDescription( } switch (status.reason) { case 'missing': - return `Orca cannot find ${status.path}. Remove and re-import this folder workspace.` + return translate( + 'auto.lib.folderWorkspacePathStatus.description.missing', + 'Orca cannot find {{path}}. Remove and re-import this folder workspace.', + { path: status.path } + ) case 'not-directory': - return `${status.path} exists, but it is not a folder.` + return translate( + 'auto.lib.folderWorkspacePathStatus.description.notDirectory', + '{{path}} exists, but it is not a folder.', + { path: status.path } + ) case 'ambiguous-connection': - return 'Orca cannot tell which SSH connection owns this folder scope.' + return translate( + 'auto.lib.folderWorkspacePathStatus.description.ambiguousConnection', + 'Orca cannot tell which SSH connection owns this folder scope.' + ) + case undefined: case 'unavailable': - default: - return 'Orca cannot verify this folder right now. Check the runtime or SSH connection and try again.' + return translate( + 'auto.lib.folderWorkspacePathStatus.description.unavailable', + 'Orca cannot verify this folder right now. Check the runtime or SSH connection and try again.' + ) } } @@ -47,30 +71,61 @@ export function formatFolderWorkspaceCreateError(error: unknown): { const path = message.includes(':') ? message.slice(message.indexOf(':') + 1) : '' if (message.startsWith('folder_workspace_path_missing:')) { return { - title: 'Folder not found', - description: `Orca cannot find ${path}. Remove and re-import the folder.` + title: translate( + 'auto.lib.folderWorkspacePathStatus.createError.title.missing', + 'Folder not found' + ), + description: translate( + 'auto.lib.folderWorkspacePathStatus.createError.description.missing', + 'Orca cannot find {{path}}. Remove and re-import the folder.', + { path } + ) } } if (message.startsWith('folder_workspace_path_not_directory:')) { return { - title: 'Path is not a folder', - description: `${path} exists, but it is not a folder.` + title: translate( + 'auto.lib.folderWorkspacePathStatus.createError.title.notDirectory', + 'Path is not a folder' + ), + description: translate( + 'auto.lib.folderWorkspacePathStatus.createError.description.notDirectory', + '{{path}} exists, but it is not a folder.', + { path } + ) } } if (message.startsWith('folder_workspace_connection_ambiguous:')) { return { - title: 'Cannot determine connection', - description: 'Orca cannot tell which SSH connection owns this folder scope.' + title: translate( + 'auto.lib.folderWorkspacePathStatus.createError.title.ambiguousConnection', + 'Cannot determine connection' + ), + description: translate( + 'auto.lib.folderWorkspacePathStatus.createError.description.ambiguousConnection', + 'Orca cannot tell which SSH connection owns this folder scope.' + ) } } if (message.startsWith('folder_workspace_path_unavailable:')) { return { - title: 'Cannot check folder', - description: + title: translate( + 'auto.lib.folderWorkspacePathStatus.createError.title.unavailable', + 'Cannot check folder' + ), + description: translate( + 'auto.lib.folderWorkspacePathStatus.createError.description.unavailable', 'Orca cannot verify this folder right now. Check the runtime or SSH connection and try again.' + ) } } - return { title: 'Failed to create folder workspace', description: message } + return { + title: translate( + 'auto.lib.folderWorkspacePathStatus.createError.title.generic', + 'Failed to create folder workspace' + ), + description: message + } } export function folderWorkspaceActivationBlocked( diff --git a/src/shared/types.ts b/src/shared/types.ts index cde79b12f7e..90dc0bb4bfb 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1382,8 +1382,12 @@ export type GitHubCreateIssueFields = { assignees?: string[] } +export type GitHubIssueCloseReason = 'completed' | 'not_planned' | 'duplicate' + export type GitHubIssueUpdate = { state?: 'open' | 'closed' + stateReason?: GitHubIssueCloseReason + duplicateOf?: number title?: string // Why: body writes use the REST issue endpoint instead of `gh issue edit` // because that command does not consistently cover every body-edit case the