diff --git a/config/scripts/localize-renderer-strings.mjs b/config/scripts/localize-renderer-strings.mjs index 34b01c7370c..459a16ee387 100644 --- a/config/scripts/localize-renderer-strings.mjs +++ b/config/scripts/localize-renderer-strings.mjs @@ -74,7 +74,9 @@ function editForCandidate(candidate, key, translation, sourceFile) { } function sourceKindForPath(filePath) { - return filePath.endsWith('.tsx') || filePath.endsWith('.jsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS + return filePath.endsWith('.tsx') || filePath.endsWith('.jsx') + ? ts.ScriptKind.TSX + : ts.ScriptKind.TS } function findNodeByRange(sourceFile, start, end) { @@ -201,7 +203,9 @@ async function collectCandidateFiles(root) { for (const entry of entries) { const fullPath = path.join(dir, entry.name) if (entry.isDirectory()) { - if (!['.git', 'assets', 'dist', 'node_modules', 'out', '__snapshots__'].includes(entry.name)) { + if ( + !['.git', 'assets', 'dist', 'node_modules', 'out', '__snapshots__'].includes(entry.name) + ) { stack.push(fullPath) } continue diff --git a/src/main/ipc/telemetry.test.ts b/src/main/ipc/telemetry.test.ts index 4d02a8d1df6..162ee86ba7a 100644 --- a/src/main/ipc/telemetry.test.ts +++ b/src/main/ipc/telemetry.test.ts @@ -160,6 +160,12 @@ describe('telemetry IPC handlers', () => { registerWith({ installId: 'x', existedBeforeTelemetryRelease: false, optedIn: true }) const handler = handlers.get('telemetry:track')! handler({}, 'app_starred_orca', { source: 'settings' }) + handler({}, 'feature_interaction_usage_bucket_reached', { + feature_id: 'tasks', + feature_category: 'task_management', + count_bucket: 'count_1', + bucket_source: 'crossed_now' + }) expect(trackMock).not.toHaveBeenCalled() expect(getCohortAtEmitMock).not.toHaveBeenCalled() }) diff --git a/src/main/ipc/telemetry.ts b/src/main/ipc/telemetry.ts index c5fe79b833d..a4faa9d7927 100644 --- a/src/main/ipc/telemetry.ts +++ b/src/main/ipc/telemetry.ts @@ -53,7 +53,10 @@ import type { OptInVia } from '../../shared/telemetry-events' // mirrors how other core-handlers accept the store explicitly. let storeRef: Store | null = null -const MAIN_OWNED_TELEMETRY_EVENTS = new Set(['app_starred_orca']) +const MAIN_OWNED_TELEMETRY_EVENTS = new Set([ + 'app_starred_orca', + 'feature_interaction_usage_bucket_reached' +]) /** * Derive the `via` discriminator for a `telemetry:setOptIn` call from diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 3dfe7ab4ec2..4f9f9b8179c 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -81,6 +81,11 @@ const WORKFLOW_DEFAULT_WORKSPACE_STATUSES = [ { id: 'todo', label: 'Todo', color: 'neutral', icon: 'circle' } ] +const { trackMock, getCohortAtEmitMock } = vi.hoisted(() => ({ + trackMock: vi.fn(), + getCohortAtEmitMock: vi.fn() +})) + vi.mock('electron', () => ({ app: { getPath: () => testState.dir @@ -102,6 +107,14 @@ vi.mock('./git/repo', () => ({ getGitUsername: vi.fn().mockReturnValue('testuser') })) +vi.mock('./telemetry/client', () => ({ + track: trackMock +})) + +vi.mock('./telemetry/cohort-classifier', () => ({ + getCohortAtEmit: getCohortAtEmitMock +})) + /** Reset modules and dynamically import Store so the data-file path picks up the current testState.dir */ async function createStore() { vi.resetModules() @@ -257,6 +270,9 @@ function makeBalancedLegacyPaneLayout(start: number, end: number): TerminalPaneL describe('Store', () => { beforeEach(() => { testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) + trackMock.mockReset() + getCohortAtEmitMock.mockReset() + getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 }) }) afterEach(() => { @@ -268,7 +284,7 @@ describe('Store', () => { it('returns empty repos when no data file exists', async () => { const store = await createStore() expect(store.getRepos()).toEqual([]) - }) + }, 15_000) it('returns default settings when no data file exists', async () => { const store = await createStore() @@ -3037,6 +3053,61 @@ describe('Store', () => { }) }) + it('normalizes malformed main-owned feature telemetry bucket markers on read', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {}, + featureInteractionTelemetryBuckets: { + tasks: 'count_2', + browser: 'count_4', + unknown: 'count_1' + } + }) + + const store = await createStore() + store.flush() + + const persisted = readDataFile() as PersistedState + expect(persisted.featureInteractionTelemetryBuckets).toEqual({ tasks: 'count_2' }) + }) + + it('does not expose or accept UI shadow writes for main-owned feature telemetry markers', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: { + featureInteractionTelemetryBuckets: { tasks: 'count_1000_plus' } + }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {}, + featureInteractionTelemetryBuckets: { tasks: 'count_2' } + }) + + const store = await createStore() + + expect('featureInteractionTelemetryBuckets' in (store.getUI() as Record)).toBe( + false + ) + + store.updateUI({ + featureInteractionTelemetryBuckets: { tasks: 'count_500_999' } + } as never) + store.flush() + + const persisted = readDataFile() as PersistedState & { + ui: Record + } + expect(persisted.featureInteractionTelemetryBuckets).toEqual({ tasks: 'count_2' }) + expect(persisted.ui.featureInteractionTelemetryBuckets).toBeUndefined() + }) + it('normalizes feature tip ids from direct UI writes', async () => { const store = await createStore() @@ -3068,6 +3139,179 @@ describe('Store', () => { }) }) + it('emits feature interaction telemetry only when a higher bucket is reached', async () => { + const store = await createStore() + + store.recordFeatureInteraction('tasks') + store.recordFeatureInteraction('tasks') + store.recordFeatureInteraction('tasks') + store.recordFeatureInteraction('tasks') + store.flush() + + expect(trackMock).toHaveBeenCalledTimes(3) + expect(trackMock).toHaveBeenNthCalledWith(1, 'feature_interaction_usage_bucket_reached', { + feature_id: 'tasks', + feature_category: 'task_management', + count_bucket: 'count_1', + bucket_source: 'crossed_now', + nth_repo_added: 2 + }) + expect(trackMock).toHaveBeenNthCalledWith(2, 'feature_interaction_usage_bucket_reached', { + feature_id: 'tasks', + feature_category: 'task_management', + count_bucket: 'count_2', + bucket_source: 'crossed_now', + nth_repo_added: 2 + }) + expect(trackMock).toHaveBeenNthCalledWith(3, 'feature_interaction_usage_bucket_reached', { + feature_id: 'tasks', + feature_category: 'task_management', + count_bucket: 'count_3_4', + bucket_source: 'crossed_now', + nth_repo_added: 2 + }) + expect((readDataFile() as PersistedState).featureInteractionTelemetryBuckets).toEqual({ + tasks: 'count_3_4' + }) + }) + + it('emits one observed-existing bucket for pre-rollout interaction counts', async () => { + const store = await createStore() + store.updateUI({ + featureInteractions: { + tasks: { firstInteractedAt: 100, interactionCount: 137 } + } + }) + trackMock.mockClear() + + store.recordFeatureInteraction('tasks') + store.recordFeatureInteraction('tasks') + store.flush() + + expect(trackMock).toHaveBeenCalledTimes(1) + expect(trackMock).toHaveBeenCalledWith('feature_interaction_usage_bucket_reached', { + feature_id: 'tasks', + feature_category: 'task_management', + count_bucket: 'count_100_199', + bucket_source: 'observed_existing', + nth_repo_added: 2 + }) + expect((readDataFile() as PersistedState).featureInteractionTelemetryBuckets).toEqual({ + tasks: 'count_100_199' + }) + }) + + it('emits only the top-coded observed-existing bucket for pre-rollout power users', async () => { + const store = await createStore() + store.updateUI({ + featureInteractions: { + tasks: { firstInteractedAt: 100, interactionCount: 1200 } + } + }) + trackMock.mockClear() + + store.recordFeatureInteraction('tasks') + + expect(trackMock).toHaveBeenCalledTimes(1) + expect(trackMock).toHaveBeenCalledWith('feature_interaction_usage_bucket_reached', { + feature_id: 'tasks', + feature_category: 'task_management', + count_bucket: 'count_1000_plus', + bucket_source: 'observed_existing', + nth_repo_added: 2 + }) + }) + + it('emits high bucket crossings once and ignores same-range increments', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: { + featureInteractions: { + tasks: { firstInteractedAt: 100, interactionCount: 198 } + } + }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {}, + featureInteractionTelemetryBuckets: { tasks: 'count_100_199' } + }) + const store = await createStore() + + store.recordFeatureInteraction('tasks') + store.recordFeatureInteraction('tasks') + + expect(trackMock).toHaveBeenCalledTimes(1) + expect(trackMock).toHaveBeenCalledWith('feature_interaction_usage_bucket_reached', { + feature_id: 'tasks', + feature_category: 'task_management', + count_bucket: 'count_200_499', + bucket_source: 'crossed_now', + nth_repo_added: 2 + }) + }) + + it('does not emit for count 4 but emits the count_1000_plus crossing', async () => { + const store = await createStore() + + store.recordFeatureInteraction('tasks') + store.recordFeatureInteraction('tasks') + store.recordFeatureInteraction('tasks') + trackMock.mockClear() + + store.recordFeatureInteraction('tasks') + expect(trackMock).not.toHaveBeenCalled() + + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: { + featureInteractions: { + tasks: { firstInteractedAt: 100, interactionCount: 999 } + } + }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {}, + featureInteractionTelemetryBuckets: { tasks: 'count_500_999' } + }) + const reloaded = await createStore() + + reloaded.recordFeatureInteraction('tasks') + expect(trackMock).toHaveBeenCalledTimes(1) + expect(trackMock).toHaveBeenCalledWith('feature_interaction_usage_bucket_reached', { + feature_id: 'tasks', + feature_category: 'task_management', + count_bucket: 'count_1000_plus', + bucket_source: 'crossed_now', + nth_repo_added: 2 + }) + }) + + it('dedupes against the persisted bucket marker', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: { + featureInteractions: { + tasks: { firstInteractedAt: 100, interactionCount: 100 } + } + }, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {}, + featureInteractionTelemetryBuckets: { tasks: 'count_100_199' } + }) + const store = await createStore() + + store.recordFeatureInteraction('tasks') + + expect(trackMock).not.toHaveBeenCalled() + }) + it('updateUI restores fixed card properties from direct UI writes', async () => { const store = await createStore() store.updateUI({ worktreeCardProperties: ['inline-agents'] }) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 395049ca32b..71aeb870e83 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -94,7 +94,11 @@ import { normalizeTerminalShortcutPolicy } from '../shared/keybindings' import { normalizeAppIconId } from '../shared/app-icon' import { normalizeTerminalCustomThemes } from '../shared/terminal-custom-themes' import { + compareFeatureInteractionUsageBuckets, + getFeatureInteractionCategory, + getFeatureInteractionUsageBucket, normalizeFeatureInteractions, + normalizeFeatureInteractionTelemetryBuckets, type FeatureInteractionId } from '../shared/feature-interactions' import { normalizeContextualTourIds } from '../shared/contextual-tours' @@ -134,6 +138,8 @@ import { migrateWorkspaceSessionTerminalScrollbackSnapshots, readTerminalScrollbackSnapshotSync } from './terminal-scrollback-snapshots' +import { track } from './telemetry/client' +import { getCohortAtEmit } from './telemetry/cohort-classifier' function encrypt(plaintext: string): string { if (!plaintext || !safeStorage.isEncryptionAvailable()) { @@ -324,6 +330,21 @@ function mergeContextualTourSeenIds( return [...merged] } +function stripMainOwnedTelemetryMarkerFromUI( + value: Partial | undefined +): Partial { + if (!value || typeof value !== 'object') { + return {} + } + const { featureInteractionTelemetryBuckets: _reserved, ...ui } = value as Partial< + PersistedState['ui'] + > & { + featureInteractionTelemetryBuckets?: unknown + } + void _reserved + return ui +} + function normalizeSortBy(sortBy: unknown): PersistedState['ui']['sortBy'] { if ( sortBy === 'smart' || @@ -1900,6 +1921,9 @@ export class Store { result = { ...defaults, ...parsed, + featureInteractionTelemetryBuckets: normalizeFeatureInteractionTelemetryBuckets( + parsed.featureInteractionTelemetryBuckets + ), projectGroups: normalizeProjectGroups(parsed.projectGroups), worktreeLineageById: parsed.worktreeLineageById ?? {}, settings: { @@ -2106,7 +2130,7 @@ export class Store { } return { ...defaults.ui, - ...parsed.ui, + ...stripMainOwnedTelemetryMarkerFromUI(parsed.ui), // Why: migrate once from the retired Appearance setting only // when no explicit persisted chrome preference exists yet. rightSidebarOpen, @@ -3175,9 +3199,10 @@ export class Store { // ── UI State ─────────────────────────────────────────────────────── getUI(): PersistedState['ui'] { + const uiState = stripMainOwnedTelemetryMarkerFromUI(this.state.ui) return { ...getDefaultUIState(), - ...this.state.ui, + ...uiState, groupBy: normalizeGroupBy(this.state.ui?.groupBy), sortBy: normalizeSortBy(this.state.ui?.sortBy), projectOrderBy: normalizeProjectOrderBy(this.state.ui?.projectOrderBy), @@ -3206,39 +3231,44 @@ export class Store { } updateUI(updates: Partial): void { + const sanitizedUpdates = stripMainOwnedTelemetryMarkerFromUI(updates) + const currentUI = { + ...getDefaultUIState(), + ...stripMainOwnedTelemetryMarkerFromUI(this.state.ui) + } this.state.ui = { - ...this.state.ui, - ...updates, - groupBy: updates.groupBy - ? normalizeGroupBy(updates.groupBy) + ...currentUI, + ...sanitizedUpdates, + groupBy: sanitizedUpdates.groupBy + ? normalizeGroupBy(sanitizedUpdates.groupBy) : normalizeGroupBy(this.state.ui?.groupBy), - sortBy: updates.sortBy - ? normalizeSortBy(updates.sortBy) + sortBy: sanitizedUpdates.sortBy + ? normalizeSortBy(sanitizedUpdates.sortBy) : normalizeSortBy(this.state.ui?.sortBy), projectOrderBy: updates.projectOrderBy ? normalizeProjectOrderBy(updates.projectOrderBy) : normalizeProjectOrderBy(this.state.ui?.projectOrderBy), rightSidebarTab: - updates.rightSidebarTab !== undefined - ? normalizeRightSidebarTab(updates.rightSidebarTab) + sanitizedUpdates.rightSidebarTab !== undefined + ? normalizeRightSidebarTab(sanitizedUpdates.rightSidebarTab) : normalizeRightSidebarTab(this.state.ui?.rightSidebarTab), worktreeCardProperties: - updates.worktreeCardProperties !== undefined - ? normalizeWorktreeCardProperties(updates.worktreeCardProperties) + sanitizedUpdates.worktreeCardProperties !== undefined + ? normalizeWorktreeCardProperties(sanitizedUpdates.worktreeCardProperties) : normalizeWorktreeCardProperties(this.state.ui?.worktreeCardProperties), agentActivityDisplayMode: updates.agentActivityDisplayMode !== undefined ? normalizeAgentActivityDisplayMode(updates.agentActivityDisplayMode) : normalizeAgentActivityDisplayMode(this.state.ui?.agentActivityDisplayMode), workspaceStatuses: - updates.workspaceStatuses !== undefined - ? normalizeWorkspaceStatuses(updates.workspaceStatuses) + sanitizedUpdates.workspaceStatuses !== undefined + ? normalizeWorkspaceStatuses(sanitizedUpdates.workspaceStatuses) : normalizeWorkspaceStatuses(this.state.ui?.workspaceStatuses), workspaceBoardOpacity: clampWorkspaceBoardOpacity( - updates.workspaceBoardOpacity ?? this.state.ui?.workspaceBoardOpacity + sanitizedUpdates.workspaceBoardOpacity ?? this.state.ui?.workspaceBoardOpacity ), workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth( - updates.workspaceBoardColumnWidth ?? this.state.ui?.workspaceBoardColumnWidth + sanitizedUpdates.workspaceBoardColumnWidth ?? this.state.ui?.workspaceBoardColumnWidth ), browserDefaultZoomLevel: normalizeBrowserPageZoomLevel( updates.browserDefaultZoomLevel ?? this.state.ui?.browserDefaultZoomLevel @@ -3248,8 +3278,8 @@ export class Store { ? normalizeShowDotfilesByWorktree(updates.showDotfilesByWorktree) : normalizeShowDotfilesByWorktree(this.state.ui?.showDotfilesByWorktree), featureTipsSeenIds: - updates.featureTipsSeenIds !== undefined - ? normalizeFeatureTipIds(updates.featureTipsSeenIds) + sanitizedUpdates.featureTipsSeenIds !== undefined + ? normalizeFeatureTipIds(sanitizedUpdates.featureTipsSeenIds) : normalizeFeatureTipIds(this.state.ui?.featureTipsSeenIds), // Why: renderer and paired clients can mark different tours seen from // stale UI snapshots; union them so completed tours stay suppressed. @@ -3264,10 +3294,10 @@ export class Store { // Merge instead of replacing so a stale renderer snapshot cannot erase // runtime-only feature interactions. featureInteractions: - updates.featureInteractions !== undefined + sanitizedUpdates.featureInteractions !== undefined ? mergeFeatureInteractions( this.state.ui?.featureInteractions, - updates.featureInteractions + sanitizedUpdates.featureInteractions ) : normalizeFeatureInteractions(this.state.ui?.featureInteractions) } @@ -3276,16 +3306,46 @@ export class Store { recordFeatureInteraction(id: FeatureInteractionId): PersistedState['ui'] { const featureInteractions = normalizeFeatureInteractions(this.state.ui?.featureInteractions) + const telemetryBuckets = normalizeFeatureInteractionTelemetryBuckets( + this.state.featureInteractionTelemetryBuckets + ) const existing = featureInteractions[id] + const previousCount = existing?.interactionCount ?? 0 + const nextCount = previousCount + 1 + const previousBucket = getFeatureInteractionUsageBucket(previousCount) + const nextBucket = getFeatureInteractionUsageBucket(nextCount) + const lastEmittedBucket = telemetryBuckets[id] ?? null + const shouldEmit = + nextBucket !== null && + (lastEmittedBucket === null || + compareFeatureInteractionUsageBuckets(nextBucket, lastEmittedBucket) > 0) + this.updateUI({ featureInteractions: { ...featureInteractions, [id]: { firstInteractedAt: existing?.firstInteractedAt ?? Date.now(), - interactionCount: (existing?.interactionCount ?? 0) + 1 + interactionCount: nextCount } } }) + this.state.featureInteractionTelemetryBuckets = shouldEmit + ? { ...telemetryBuckets, [id]: nextBucket } + : telemetryBuckets + this.scheduleSave() + + if (shouldEmit) { + track('feature_interaction_usage_bucket_reached', { + feature_id: id, + feature_category: getFeatureInteractionCategory(id), + count_bucket: nextBucket, + bucket_source: + lastEmittedBucket === null && previousBucket !== null && previousBucket === nextBucket + ? 'observed_existing' + : 'crossed_now', + ...getCohortAtEmit() + }) + } return this.getUI() } diff --git a/src/renderer/src/components/CodexRestartChip.tsx b/src/renderer/src/components/CodexRestartChip.tsx index 89c42cb2398..d2455f0c7e0 100644 --- a/src/renderer/src/components/CodexRestartChip.tsx +++ b/src/renderer/src/components/CodexRestartChip.tsx @@ -74,7 +74,11 @@ export default function CodexRestartChip({
- {translate("auto.components.CodexRestartChip.9263e75f49", "Codex is using the previous account")} + {translate( + 'auto.components.CodexRestartChip.9263e75f49', + 'Codex is using the previous account' + )} +
+ {translate('auto.components.CodexRestartChip.c72a5fb234', 'Restart')} + + {translate('auto.components.CodexRestartChip.9132779820', 'Dismiss')} +
diff --git a/src/renderer/src/components/FirstLaunchBanner.tsx b/src/renderer/src/components/FirstLaunchBanner.tsx index 9a44ed3a6ed..2237770e2f2 100644 --- a/src/renderer/src/components/FirstLaunchBanner.tsx +++ b/src/renderer/src/components/FirstLaunchBanner.tsx @@ -128,21 +128,30 @@ export function FirstLaunchBanner({
{/* Text column — title + body stack on the left, takes remaining width so the action column never pushes copy into a wrap. */}
-

{translate("auto.components.FirstLaunchBanner.9784b4d7bc", "Help us decide what to build next")}

+

+ {translate( + 'auto.components.FirstLaunchBanner.9784b4d7bc', + 'Help us decide what to build next' + )} +

- {translate("auto.components.FirstLaunchBanner.958d2cc31b", "Anonymous counts of which features you use help us prioritize what to build. No file contents, prompts, terminal output, or anything that identifies you. Change anytime in Settings -> Privacy & Telemetry.")}{' '} + {translate( + 'auto.components.FirstLaunchBanner.958d2cc31b', + 'Anonymous counts of which features you use help us prioritize what to build. No file contents, prompts, terminal output, or anything that identifies you. Change anytime in Settings -> Privacy & Telemetry.' + )}{' '} + {translate('auto.components.FirstLaunchBanner.d1deebb050', 'Privacy policy')} + .

@@ -160,15 +169,17 @@ export function FirstLaunchBanner({ disabled={inFlight} className="border-border/60 text-muted-foreground" > - {translate("auto.components.FirstLaunchBanner.fc5cc29955", "Opt out")} + {translate('auto.components.FirstLaunchBanner.fc5cc29955', 'Opt out')} + + {translate('auto.components.FirstLaunchBanner.94cc673726', 'Got it')} +
{/* aria-label says "Dismiss" — the action persists silent opt-in, not just hides the UI. */} ) : null} {comment.createdAt ? new Date(comment.createdAt).toLocaleDateString() : ''} @@ -260,7 +264,8 @@ function PipelineJobRow({ className="h-6" > {retrying ? : null} - {translate("auto.components.GitLabItemDialog.fa3e042203", "Retry")} + {translate('auto.components.GitLabItemDialog.fa3e042203', 'Retry')} + ) : null} {job.webUrl ? ( @@ -278,19 +283,23 @@ function PipelineJobRow({ {expanded ? (
- {translate("auto.components.GitLabItemDialog.2f9b27f838", "Job log")} + {translate('auto.components.GitLabItemDialog.2f9b27f838', 'Job log')} + {translate('auto.components.GitLabItemDialog.028bde664e', 'Hide')} +
{traceState?.loading ? (
- {translate("auto.components.GitLabItemDialog.d600c2619a", "Loading log")}
+ {translate('auto.components.GitLabItemDialog.d600c2619a', 'Loading log')} +
) : traceState?.error ? (
{traceState.error}
) : (
-              {traceState?.trace?.trim() ? traceState.trace : translate("auto.components.GitLabItemDialog.32f8bef818", "No log output.")}
+              {traceState?.trace?.trim()
+                ? traceState.trace
+                : translate('auto.components.GitLabItemDialog.32f8bef818', 'No log output.')}
             
)} @@ -484,7 +493,7 @@ export default function GitLabItemDialog({ const nextBody = bodyDraft const nextLabels = parseGitLabLabelDraft(labelDraft) if (!nextTitle) { - toast.error(translate("auto.components.GitLabItemDialog.98718490e4", "MR title is required.")) + toast.error(translate('auto.components.GitLabItemDialog.98718490e4', 'MR title is required.')) return } @@ -531,6 +540,7 @@ export default function GitLabItemDialog({ setTitleDraft('') setBodyDraft('') setLabelDraft('') + useAppStore.getState().recordFeatureInteraction('gitlab-tasks') } } else if (mountedRef.current) { toast.error(res.error) @@ -611,7 +621,11 @@ export default function GitLabItemDialog({ return } if (result.ok) { - toast.success(translate("auto.components.GitLabItemDialog.f7cb495a12", "Retried {{value0}}", { value0: job.name })) + toast.success( + translate('auto.components.GitLabItemDialog.f7cb495a12', 'Retried {{value0}}', { + value0: job.name + }) + ) if (result.job) { setDetails((current) => current @@ -646,7 +660,12 @@ export default function GitLabItemDialog({ .map((reviewer) => reviewer.id) .filter((id): id is number => typeof id === 'number') if (reviewerIds.length !== nextReviewers.length) { - toast.error(translate("auto.components.GitLabItemDialog.ceaf7c30c7", "Reviewer id is unavailable for this GitLab user.")) + toast.error( + translate( + 'auto.components.GitLabItemDialog.ceaf7c30c7', + 'Reviewer id is unavailable for this GitLab user.' + ) + ) return } setReviewerUpdating(true) @@ -668,6 +687,7 @@ export default function GitLabItemDialog({ setReviewerOptions((current) => current ? dedupeGitLabUsers([...current, ...result.reviewers]) : current ) + useAppStore.getState().recordFeatureInteraction('gitlab-tasks') } else { toast.error(result.error) } @@ -688,11 +708,21 @@ export default function GitLabItemDialog({ const line = Number.parseInt(inlineCommentLine, 10) const body = inlineCommentBody.trim() if (!file || !Number.isFinite(line) || line <= 0 || !body) { - toast.error(translate("auto.components.GitLabItemDialog.00d0d25825", "File, line, and comment are required.")) + toast.error( + translate( + 'auto.components.GitLabItemDialog.00d0d25825', + 'File, line, and comment are required.' + ) + ) return } if (!details.baseSha || !details.startSha || !details.headSha) { - toast.error(translate("auto.components.GitLabItemDialog.ffdd9a78e1", "MR diff refs are unavailable for inline comments.")) + toast.error( + translate( + 'auto.components.GitLabItemDialog.ffdd9a78e1', + 'MR diff refs are unavailable for inline comments.' + ) + ) return } setInlineCommentSubmitting(true) @@ -719,7 +749,10 @@ export default function GitLabItemDialog({ current ? { ...current, comments: [...current.comments, result.comment] } : current ) setInlineCommentBody('') - toast.success(translate("auto.components.GitLabItemDialog.60c13320c4", "Inline comment added")) + useAppStore.getState().recordFeatureInteraction('gitlab-tasks') + toast.success( + translate('auto.components.GitLabItemDialog.60c13320c4', 'Inline comment added') + ) } else { toast.error(result.error) } @@ -747,7 +780,12 @@ export default function GitLabItemDialog({ const res = await window.api.gl.closeMR({ repoPath, iid: item.number }) if (res.ok) { if (mountedRef.current) { - toast.success(translate("auto.components.GitLabItemDialog.9b11cd233f", "Closed MR !{{value0}}", { value0: item.number })) + useAppStore.getState().recordFeatureInteraction('gitlab-tasks') + toast.success( + translate('auto.components.GitLabItemDialog.9b11cd233f', 'Closed MR !{{value0}}', { + value0: item.number + }) + ) handleRefresh() } } else { @@ -771,7 +809,12 @@ export default function GitLabItemDialog({ const res = await window.api.gl.reopenMR({ repoPath, iid: item.number }) if (res.ok) { if (mountedRef.current) { - toast.success(translate("auto.components.GitLabItemDialog.865ea2703e", "Reopened MR !{{value0}}", { value0: item.number })) + useAppStore.getState().recordFeatureInteraction('gitlab-tasks') + toast.success( + translate('auto.components.GitLabItemDialog.865ea2703e', 'Reopened MR !{{value0}}', { + value0: item.number + }) + ) handleRefresh() } } else { @@ -795,7 +838,12 @@ export default function GitLabItemDialog({ const res = await window.api.gl.mergeMR({ repoPath, iid: item.number }) if (res.ok) { if (mountedRef.current) { - toast.success(translate("auto.components.GitLabItemDialog.e089f62594", "Merged MR !{{value0}}", { value0: item.number })) + useAppStore.getState().recordFeatureInteraction('gitlab-tasks') + toast.success( + translate('auto.components.GitLabItemDialog.e089f62594', 'Merged MR !{{value0}}', { + value0: item.number + }) + ) handleRefresh() } } else { @@ -828,6 +876,7 @@ export default function GitLabItemDialog({ setCommentDraftState((current) => current.itemId === itemId ? { itemId, value: '' } : current ) + useAppStore.getState().recordFeatureInteraction('gitlab-tasks') handleRefresh() } } else { @@ -867,6 +916,7 @@ export default function GitLabItemDialog({ } : current ) + useAppStore.getState().recordFeatureInteraction('gitlab-tasks') } } else if (mountedRef.current) { toast.error(res.error) @@ -907,8 +957,14 @@ export default function GitLabItemDialog({ !open && onClose()}> - {item ? visibleTitle : translate("auto.components.GitLabItemDialog.3a051b8ade", "Work item")} - {translate("auto.components.GitLabItemDialog.30c97083c2", "GitLab work item detail")} + + {item + ? visibleTitle + : translate('auto.components.GitLabItemDialog.3a051b8ade', 'Work item')} + + + {translate('auto.components.GitLabItemDialog.30c97083c2', 'GitLab work item detail')} + {item ? ( @@ -923,7 +979,12 @@ export default function GitLabItemDialog({ {item.number} - {item.author ? {translate("auto.components.GitLabItemDialog.9bfb4a24d7", "by")}{item.author} : null} + {item.author ? ( + + {translate('auto.components.GitLabItemDialog.9bfb4a24d7', 'by')} + {item.author} + + ) : null}

{visibleTitle} @@ -944,7 +1005,7 @@ export default function GitLabItemDialog({ + {translate('auto.components.GitLabItemDialog.cb55b0390f', 'Manage')} +
{currentReviewers.length > 0 ? ( @@ -1043,14 +1126,23 @@ export default function GitLabItemDialog({ ) } className="rounded-full p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-50" - aria-label={translate("auto.components.GitLabItemDialog.1b19cdc510", "Remove reviewer {{value0}}", { value0: reviewer.username })} + aria-label={translate( + 'auto.components.GitLabItemDialog.1b19cdc510', + 'Remove reviewer {{value0}}', + { value0: reviewer.username } + )} > )) ) : ( - {translate("auto.components.GitLabItemDialog.474b50d988", "No reviewers.")} + + {translate( + 'auto.components.GitLabItemDialog.474b50d988', + 'No reviewers.' + )} + )}
{reviewerOptions ? ( @@ -1061,7 +1153,12 @@ export default function GitLabItemDialog({ onChange={(event) => setReviewerDraftId(event.target.value)} className="h-8 min-w-0 flex-1 rounded-md border border-input bg-background px-2 text-xs text-foreground" > - + {reviewerOptionRows.map((reviewer) => (