diff --git a/apps/desktop/src/main/rendererCsp.test.ts b/apps/desktop/src/main/rendererCsp.test.ts index bfacf7295..a10ff0aa5 100644 --- a/apps/desktop/src/main/rendererCsp.test.ts +++ b/apps/desktop/src/main/rendererCsp.test.ts @@ -35,6 +35,19 @@ describe("buildRendererCspPolicy", () => { expect(policy).toContain("frame-src 'self' file: app: http://localhost:* http://127.0.0.1:* about:"); }); + it("allows only the welcome video YouTube embed hosts for external frames", () => { + const policy = buildRendererCspPolicy(false); + const frameSrc = policy.split("; ").find((directive) => directive.startsWith("frame-src ")); + const frameTokens = frameSrc?.split(/\s+/).slice(1) ?? []; + const externalFrameTokens = frameTokens.filter((token) => token.startsWith("https://")); + + expect(externalFrameTokens).toEqual([ + "https://www.youtube-nocookie.com", + "https://www.youtube.com", + ]); + expect(frameTokens).not.toContain("https:"); + }); + it("does not allow arbitrary public Google Cloud Storage image beacons", () => { const policy = buildRendererCspPolicy(false); diff --git a/apps/desktop/src/main/rendererCsp.ts b/apps/desktop/src/main/rendererCsp.ts index d9b5abbe3..196aac8c4 100644 --- a/apps/desktop/src/main/rendererCsp.ts +++ b/apps/desktop/src/main/rendererCsp.ts @@ -14,13 +14,14 @@ export function buildRendererCspPolicy(isDevMode: boolean): string { // the allowlist host-scoped (no blanket `https:`) to preserve the existing // posture of not allowing arbitrary public image beacons. const cspImageSources = `${cspSources}${cspLocalSources} https://avatars.githubusercontent.com https://*.githubusercontent.com https://user-images.githubusercontent.com https://private-user-images.githubusercontent.com https://media.githubusercontent.com https://camo.githubusercontent.com https://objects.githubusercontent.com https://github.githubassets.com https://opengraph.githubassets.com https://github.com https://vercel.com https://*.vercel.com https://img.shields.io https://*.s3.amazonaws.com https://www.gravatar.com https://secure.gravatar.com https://ade-app.dev`; + const cspFrameSources = `${cspSources}${cspLocalSources} about: https://www.youtube-nocookie.com https://www.youtube.com`; const cspScriptSources = isDevMode ? `${cspSources} 'unsafe-inline'` : cspSources; return [ `default-src ${cspSources}`, `base-uri 'self'`, `form-action 'self'`, `object-src 'none'`, - `frame-src ${cspSources}${cspLocalSources} about:`, + `frame-src ${cspFrameSources}`, `script-src ${cspScriptSources}`, `style-src ${cspSources} 'unsafe-inline'`, `img-src ${cspImageSources} ade-artifact: data: blob:`, diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index 81fd12707..969f8f83a 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -502,21 +502,8 @@ export const ADE_ACTION_ALLOWLIST: Partial { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed || Number.isNaN(Date.parse(trimmed))) return null; + return trimmed; + }; + + const normalizeWelcomeVideoState = ( + value: AppWelcomeVideoState | null | undefined, + ): AppWelcomeVideoState => { + if ( + value?.videoId === ADE_WELCOME_VIDEO_ID && + value.version === ADE_WELCOME_VIDEO_VERSION + ) { + return { + videoId: ADE_WELCOME_VIDEO_ID, + version: ADE_WELCOME_VIDEO_VERSION, + completedAt: normalizeWelcomeVideoTimestamp(value.completedAt), + dismissedAt: normalizeWelcomeVideoTimestamp(value.dismissedAt), + }; + } + return { + videoId: ADE_WELCOME_VIDEO_ID, + version: ADE_WELCOME_VIDEO_VERSION, + completedAt: null, + dismissedAt: null, + }; + }; + const getLocalRuntimeRootForEvent = (event: { sender: Electron.WebContents }): string | null => { if (!getWindowSession) return null; const windowId = BrowserWindow.fromWebContents(event.sender)?.id ?? null; @@ -2998,6 +3028,30 @@ export function registerIpc({ }; }); + ipcMain.handle(IPC.appGetWelcomeVideoState, async (): Promise => { + const state = readGlobalState(globalStatePath); + return normalizeWelcomeVideoState(state.welcomeVideo); + }); + + ipcMain.handle( + IPC.appMarkWelcomeVideoSeen, + async (_event, arg: { reason?: "completed" | "dismissed" } = {}): Promise => { + const state = readGlobalState(globalStatePath); + const current = normalizeWelcomeVideoState(state.welcomeVideo); + const timestamp = new Date().toISOString(); + const next: AppWelcomeVideoState = { + ...current, + completedAt: arg.reason === "completed" ? timestamp : current.completedAt, + dismissedAt: arg.reason === "completed" ? current.dismissedAt : timestamp, + }; + writeGlobalState(globalStatePath, { + ...state, + welcomeVideo: next, + }); + return next; + }, + ); + ipcMain.handle(IPC.appSetWindowProjectTabs, async (event, arg: { rootPaths?: string[] } = {}) => { const windowId = BrowserWindow.fromWebContents(event.sender)?.id ?? null; const rootPaths = Array.isArray(arg?.rootPaths) @@ -4489,202 +4543,17 @@ export function registerIpc({ return ctx.onboardingService.complete(); }); - const emptyTourProgress = (): OnboardingTourProgress => ({ - wizardCompletedAt: null, - wizardDismissedAt: null, - tours: {}, - tourVariants: {}, - tutorial: { - completedAt: null, - dismissedAt: null, - silenced: false, - inProgress: false, - lastActIndex: 0, - ctxSnapshot: {}, - }, - glossaryTermsSeen: [], - }); - - const coerceVariant = (raw: unknown): OnboardingTourVariant => - raw === "highlights" ? "highlights" : "full"; - - ipcMain.handle(IPC.onboardingGetTourProgress, async (): Promise => { - const ctx = getCtx(); - if (!ctx.onboardingService) return emptyTourProgress(); - return ctx.onboardingService.getTourProgress(); - }); - - ipcMain.handle(IPC.onboardingMarkWizardCompleted, async (): Promise => { - const ctx = getCtx(); - if (!ctx.onboardingService) return emptyTourProgress(); - return ctx.onboardingService.markWizardCompleted(); - }); - - ipcMain.handle(IPC.onboardingMarkWizardDismissed, async (): Promise => { - const ctx = getCtx(); - if (!ctx.onboardingService) return emptyTourProgress(); - return ctx.onboardingService.markWizardDismissed(); - }); - - ipcMain.handle( - IPC.onboardingMarkTourCompleted, - async (_event, arg: { tourId: string }): Promise => { - const ctx = getCtx(); - if (!ctx.onboardingService) return emptyTourProgress(); - return ctx.onboardingService.markTourCompleted(arg?.tourId ?? ""); - }, - ); - - ipcMain.handle( - IPC.onboardingMarkTourDismissed, - async (_event, arg: { tourId: string }): Promise => { - const ctx = getCtx(); - if (!ctx.onboardingService) return emptyTourProgress(); - return ctx.onboardingService.markTourDismissed(arg?.tourId ?? ""); - }, - ); - - ipcMain.handle( - IPC.onboardingUpdateTourStep, - async (_event, arg: { tourId: string; index: number }): Promise => { - const ctx = getCtx(); - if (!ctx.onboardingService) return emptyTourProgress(); - const index = typeof arg?.index === "number" ? arg.index : 0; - return ctx.onboardingService.updateTourStep(arg?.tourId ?? "", index); - }, - ); + const emptyHelpState = (): OnboardingHelpState => ({ glossaryTermsSeen: [] }); ipcMain.handle( IPC.onboardingMarkGlossaryTermSeen, - async (_event, arg: { termId: string }): Promise => { + async (_event, arg: { termId: string }): Promise => { const ctx = getCtx(); - if (!ctx.onboardingService) return emptyTourProgress(); + if (!ctx.onboardingService) return emptyHelpState(); return ctx.onboardingService.markGlossaryTermSeen(arg?.termId ?? ""); }, ); - ipcMain.handle( - IPC.onboardingResetTourProgress, - async (_event, arg?: { tourId?: string }): Promise => { - const ctx = getCtx(); - if (!ctx.onboardingService) return emptyTourProgress(); - return ctx.onboardingService.resetTourProgress(arg?.tourId); - }, - ); - - // Variant-aware tour progress (Round 2) --------------------------------- - - ipcMain.handle( - IPC.onboardingMarkTourCompletedVariant, - async ( - _event, - arg: { tourId: string; variant: OnboardingTourVariant }, - ): Promise => { - const ctx = getCtx(); - if (!ctx.onboardingService) return emptyTourProgress(); - return ctx.onboardingService.markTourCompleted( - arg?.tourId ?? "", - coerceVariant(arg?.variant), - ); - }, - ); - - ipcMain.handle( - IPC.onboardingMarkTourDismissedVariant, - async ( - _event, - arg: { tourId: string; variant: OnboardingTourVariant }, - ): Promise => { - const ctx = getCtx(); - if (!ctx.onboardingService) return emptyTourProgress(); - return ctx.onboardingService.markTourDismissed( - arg?.tourId ?? "", - coerceVariant(arg?.variant), - ); - }, - ); - - ipcMain.handle( - IPC.onboardingUpdateTourStepVariant, - async ( - _event, - arg: { tourId: string; variant: OnboardingTourVariant; index: number }, - ): Promise => { - const ctx = getCtx(); - if (!ctx.onboardingService) return emptyTourProgress(); - const index = typeof arg?.index === "number" ? arg.index : 0; - return ctx.onboardingService.updateTourStep( - arg?.tourId ?? "", - coerceVariant(arg?.variant), - index, - ); - }, - ); - - // Tutorial (Round 2) ---------------------------------------------------- - - ipcMain.handle(IPC.onboardingTutorialStart, async (): Promise => { - const ctx = getCtx(); - if (!ctx.onboardingService) return emptyTourProgress(); - return ctx.onboardingService.markTutorialStarted(); - }); - - ipcMain.handle( - IPC.onboardingTutorialDismiss, - async (_event, arg?: { permanent?: boolean }): Promise => { - const ctx = getCtx(); - if (!ctx.onboardingService) return emptyTourProgress(); - return ctx.onboardingService.markTutorialDismissed(Boolean(arg?.permanent)); - }, - ); - - ipcMain.handle(IPC.onboardingTutorialComplete, async (): Promise => { - const ctx = getCtx(); - if (!ctx.onboardingService) return emptyTourProgress(); - return ctx.onboardingService.markTutorialCompleted(); - }); - - ipcMain.handle( - IPC.onboardingTutorialUpdateAct, - async ( - _event, - arg: { actIndex: number; ctxSnapshot?: Record }, - ): Promise => { - const ctx = getCtx(); - if (!ctx.onboardingService) return emptyTourProgress(); - const actIndex = typeof arg?.actIndex === "number" ? arg.actIndex : 0; - const snapshot = - arg?.ctxSnapshot && typeof arg.ctxSnapshot === "object" && !Array.isArray(arg.ctxSnapshot) - ? arg.ctxSnapshot - : undefined; - return ctx.onboardingService.updateTutorialAct(actIndex, snapshot); - }, - ); - - ipcMain.handle( - IPC.onboardingTutorialSetSilenced, - async (_event, arg: { silenced: boolean }): Promise => { - const ctx = getCtx(); - if (!ctx.onboardingService) return emptyTourProgress(); - return ctx.onboardingService.setTutorialSilenced(Boolean(arg?.silenced)); - }, - ); - - ipcMain.handle( - IPC.onboardingTutorialClearSessionDismissal, - async (): Promise => { - const ctx = getCtx(); - if (!ctx.onboardingService) return emptyTourProgress(); - return ctx.onboardingService.clearTutorialSessionDismissal(); - }, - ); - - ipcMain.handle(IPC.onboardingTutorialShouldPrompt, async (): Promise => { - const ctx = getCtx(); - if (!ctx.onboardingService) return false; - return ctx.onboardingService.shouldPromptTutorial(); - }); - const ensureAutomationContext = (): AppContextWith<"automationService"> => { const ctx = getCtx(); requireAppContextServices(ctx, ["automationService"] as const); diff --git a/apps/desktop/src/main/services/onboarding/onboardingService.test.ts b/apps/desktop/src/main/services/onboarding/onboardingService.test.ts index 4ce2215e1..f2dbc3669 100644 --- a/apps/desktop/src/main/services/onboarding/onboardingService.test.ts +++ b/apps/desktop/src/main/services/onboarding/onboardingService.test.ts @@ -108,13 +108,13 @@ describe("onboardingService integration", () => { }); }); -describe("onboardingService tour progress", () => { +describe("onboardingService help state", () => { function buildService() { const db = createInMemoryAdeDb(); const service = createOnboardingService({ db, logger: createLogger(), - projectRoot: "/tmp/ade-onboarding-tour", + projectRoot: "/tmp/ade-onboarding-help", projectId: "proj", baseRef: "main", freshProject: false, @@ -124,246 +124,23 @@ describe("onboardingService tour progress", () => { return { service, db }; } - it("returns an empty progress snapshot by default", () => { + it("returns an empty help snapshot by default", () => { const { service } = buildService(); - const progress = service.getTourProgress(); - expect(progress).toEqual({ - wizardCompletedAt: null, - wizardDismissedAt: null, - tours: {}, - tourVariants: {}, - tutorial: { - completedAt: null, - dismissedAt: null, - silenced: false, - inProgress: false, - lastActIndex: 0, - ctxSnapshot: {}, - }, + expect(service.getHelpState()).toEqual({ glossaryTermsSeen: [], }); }); - it("round-trips wizard completed and dismissed timestamps", () => { - const { service } = buildService(); - const completed = service.markWizardCompleted(); - expect(completed.wizardCompletedAt).toBeTruthy(); - expect(completed.wizardDismissedAt).toBeNull(); - - const dismissed = service.markWizardDismissed(); - expect(dismissed.wizardCompletedAt).toBe(completed.wizardCompletedAt); - expect(dismissed.wizardDismissedAt).toBeTruthy(); - - // Re-reading persists. - const refetched = service.getTourProgress(); - expect(refetched.wizardCompletedAt).toBe(completed.wizardCompletedAt); - expect(refetched.wizardDismissedAt).toBe(dismissed.wizardDismissedAt); - }); - - it("tracks per-tour completion, dismissal, and step index", () => { - const { service } = buildService(); - service.updateTourStep("lanes", 3); - service.markTourCompleted("lanes"); - const progress = service.getTourProgress(); - expect(progress.tours.lanes.lastStepIndex).toBe(3); - expect(progress.tours.lanes.completedAt).toBeTruthy(); - expect(progress.tours.lanes.dismissedAt).toBeNull(); - - service.markTourDismissed("work"); - const afterDismiss = service.getTourProgress(); - expect(afterDismiss.tours.work.dismissedAt).toBeTruthy(); - expect(afterDismiss.tours.work.completedAt).toBeNull(); - }); - it("records seen glossary terms without duplicates", () => { const { service } = buildService(); service.markGlossaryTermSeen("Lane"); service.markGlossaryTermSeen("Worktree"); - service.markGlossaryTermSeen("Lane"); - const progress = service.getTourProgress(); - expect(progress.glossaryTermsSeen).toEqual(["Lane", "Worktree"]); - }); - - it("resetTourProgress(tourId) clears only that tour", () => { - const { service } = buildService(); - service.markWizardCompleted(); - service.markTourCompleted("lanes"); - service.markTourCompleted("work"); - - const reset = service.resetTourProgress("lanes"); - expect(reset.tours.lanes).toBeUndefined(); - expect(reset.tours.work?.completedAt).toBeTruthy(); - expect(reset.wizardCompletedAt).toBeTruthy(); - }); - - it("resetTourProgress() with no arg clears wizard + all tours", () => { - const { service } = buildService(); - service.markWizardCompleted(); - service.markTourCompleted("lanes"); - service.markGlossaryTermSeen("Lane"); - - const reset = service.resetTourProgress(); - expect(reset).toEqual({ - wizardCompletedAt: null, - wizardDismissedAt: null, - tours: {}, - tourVariants: {}, - tutorial: { - completedAt: null, - dismissedAt: null, - silenced: false, - inProgress: false, - lastActIndex: 0, - ctxSnapshot: {}, - }, - glossaryTermsSeen: [], - }); - }); -}); - -describe("onboardingService tutorial state", () => { - function buildService() { - const db = createInMemoryAdeDb(); - const service = createOnboardingService({ - db, - logger: createLogger(), - projectRoot: "/tmp/ade-onboarding-tutorial", - projectId: "proj", - baseRef: "main", - freshProject: false, - laneService: { list: async () => [] } as any, - projectConfigService: createInMemoryProjectConfigService(), - }); - return { service, db }; - } - - it("shouldPromptTutorial is true by default", () => { - const { service } = buildService(); - expect(service.shouldPromptTutorial()).toBe(true); - }); - - it("markTutorialDismissed(false) records 'Not now' but does not silence; prompt still suppressed in-session", () => { - const { service } = buildService(); - const progress = service.markTutorialDismissed(false); - expect(progress.tutorial?.dismissedAt).toBeTruthy(); - expect(progress.tutorial?.silenced).toBe(false); - // With dismissedAt set but not silenced, same-session prompt is suppressed. - expect(service.shouldPromptTutorial()).toBe(false); - // Bootstrap clears session dismissal on launch -> prompt re-shows. - service.clearTutorialSessionDismissal(); - expect(service.shouldPromptTutorial()).toBe(true); + service.markGlossaryTermSeen(" Lane "); + expect(service.getHelpState().glossaryTermsSeen).toEqual(["Lane", "Worktree"]); }); - it("markTutorialDismissed(true) silences permanently", () => { - const { service } = buildService(); - const progress = service.markTutorialDismissed(true); - expect(progress.tutorial?.silenced).toBe(true); - expect(service.shouldPromptTutorial()).toBe(false); - // Clearing session dismissal does NOT un-silence. - service.clearTutorialSessionDismissal(); - expect(service.shouldPromptTutorial()).toBe(false); - }); - - it("markTutorialCompleted suppresses future prompts", () => { - const { service } = buildService(); - const progress = service.markTutorialCompleted(); - expect(progress.tutorial?.completedAt).toBeTruthy(); - expect(progress.tutorial?.inProgress).toBe(false); - expect(service.shouldPromptTutorial()).toBe(false); - }); - - it("updateTutorialAct persists index and snapshot; clamps to [0, 12]", () => { - const { service } = buildService(); - const progress = service.updateTutorialAct(5, { laneName: "test" }); - expect(progress.tutorial?.lastActIndex).toBe(5); - expect(progress.tutorial?.ctxSnapshot).toEqual({ laneName: "test" }); - expect(progress.tutorial?.inProgress).toBe(true); - - const clampedHigh = service.updateTutorialAct(999); - expect(clampedHigh.tutorial?.lastActIndex).toBe(12); - const clampedLow = service.updateTutorialAct(-3); - expect(clampedLow.tutorial?.lastActIndex).toBe(0); - }); - - it("setTutorialSilenced toggles the silenced flag directly", () => { - const { service } = buildService(); - service.setTutorialSilenced(true); - expect(service.shouldPromptTutorial()).toBe(false); - service.setTutorialSilenced(false); - expect(service.shouldPromptTutorial()).toBe(true); - }); - - it("markTutorialStarted clears any prior session dismissal", () => { - const { service } = buildService(); - service.markTutorialDismissed(false); - const started = service.markTutorialStarted(); - expect(started.tutorial?.inProgress).toBe(true); - expect(started.tutorial?.dismissedAt).toBeNull(); - }); -}); - -describe("onboardingService tour variants", () => { - function buildService() { - const db = createInMemoryAdeDb(); - const service = createOnboardingService({ - db, - logger: createLogger(), - projectRoot: "/tmp/ade-onboarding-variants", - projectId: "proj", - baseRef: "main", - freshProject: false, - laneService: { list: async () => [] } as any, - projectConfigService: createInMemoryProjectConfigService(), - }); - return { service, db }; - } - - it("markTourCompleted with 'full' does not affect 'highlights'", () => { - const { service } = buildService(); - service.markTourCompleted("lanes", "full"); - const progress = service.getTourProgress(); - expect(progress.tourVariants?.lanes?.full.completedAt).toBeTruthy(); - expect(progress.tourVariants?.lanes?.highlights.completedAt).toBeNull(); - }); - - it("markTourCompleted with 'highlights' does not affect 'full'", () => { - const { service } = buildService(); - service.markTourCompleted("lanes", "highlights"); - const progress = service.getTourProgress(); - expect(progress.tourVariants?.lanes?.highlights.completedAt).toBeTruthy(); - expect(progress.tourVariants?.lanes?.full.completedAt).toBeNull(); - }); - - it("updateTourStep supports (tourId, variant, index) and (tourId, index) signatures", () => { - const { service } = buildService(); - // New signature. - service.updateTourStep("lanes", "highlights", 4); - let progress = service.getTourProgress(); - expect(progress.tourVariants?.lanes?.highlights.lastStepIndex).toBe(4); - expect(progress.tourVariants?.lanes?.full.lastStepIndex).toBe(0); - - // Legacy 2-arg signature defaults to "full". - service.updateTourStep("lanes", 2); - progress = service.getTourProgress(); - expect(progress.tourVariants?.lanes?.full.lastStepIndex).toBe(2); - expect(progress.tourVariants?.lanes?.highlights.lastStepIndex).toBe(4); - // Legacy flat mirror reflects the "full" write. - expect(progress.tours.lanes.lastStepIndex).toBe(2); - }); - - it("markTourDismissed is variant-scoped", () => { - const { service } = buildService(); - service.markTourDismissed("work", "highlights"); - const progress = service.getTourProgress(); - expect(progress.tourVariants?.work?.highlights.dismissedAt).toBeTruthy(); - expect(progress.tourVariants?.work?.full.dismissedAt).toBeNull(); - }); -}); - -describe("onboardingService legacy normalization", () => { - it("normalizes a legacy flat tour entry into the new variant shape", () => { + it("preserves glossary terms from legacy tour-progress storage", () => { const db = createInMemoryAdeDb(); - // Pre-seed storage with the OLD flat schema to simulate an upgrade. db.setJson("onboarding:tourProgress", { wizardCompletedAt: "2026-01-01T00:00:00Z", wizardDismissedAt: null, @@ -374,7 +151,7 @@ describe("onboardingService legacy normalization", () => { lastStepIndex: 3, }, }, - glossaryTermsSeen: ["Lane"], + glossaryTermsSeen: [" Lane ", "", "Lane"], }); const service = createOnboardingService({ @@ -388,24 +165,6 @@ describe("onboardingService legacy normalization", () => { projectConfigService: createInMemoryProjectConfigService(), }); - const progress = service.getTourProgress(); - // Legacy field preserved. - expect(progress.tours.lanes.completedAt).toBe("2026-01-02T00:00:00Z"); - // Mirrored to variant.full with highlights at defaults. - expect(progress.tourVariants?.lanes?.full.completedAt).toBe("2026-01-02T00:00:00Z"); - expect(progress.tourVariants?.lanes?.full.lastStepIndex).toBe(3); - expect(progress.tourVariants?.lanes?.highlights.completedAt).toBeNull(); - // Tutorial defaults populated. - expect(progress.tutorial).toEqual({ - completedAt: null, - dismissedAt: null, - silenced: false, - inProgress: false, - lastActIndex: 0, - ctxSnapshot: {}, - }); - // Wizard + glossary fields preserved unchanged. - expect(progress.wizardCompletedAt).toBe("2026-01-01T00:00:00Z"); - expect(progress.glossaryTermsSeen).toEqual(["Lane"]); + expect(service.getHelpState()).toEqual({ glossaryTermsSeen: ["Lane"] }); }); }); diff --git a/apps/desktop/src/main/services/onboarding/onboardingService.ts b/apps/desktop/src/main/services/onboarding/onboardingService.ts index 35d7bd932..1255a3c6b 100644 --- a/apps/desktop/src/main/services/onboarding/onboardingService.ts +++ b/apps/desktop/src/main/services/onboarding/onboardingService.ts @@ -8,13 +8,8 @@ import type { OnboardingDetectionIndicator, OnboardingDetectionResult, OnboardingExistingLaneCandidate, + OnboardingHelpState, OnboardingStatus, - OnboardingTourEntry, - OnboardingTourEntryV2, - OnboardingTourProgress, - OnboardingTourVariant, - OnboardingTourVariantEntry, - OnboardingTutorialState, ProjectConfigFile } from "../../../shared/types"; import { runGit, runGitOrThrow } from "../git/git"; @@ -22,176 +17,30 @@ import { dirExists, fileExists, nowIso } from "../shared/utils"; import { buildSuggestedConfig, parseGithubWorkflowRuns } from "./onboardingSuggestedConfig"; const STATUS_KEY = "onboarding:status"; -const TOUR_PROGRESS_KEY = "onboarding:tourProgress"; +// Historical storage key retained only so existing glossary terms are not lost +// when upgrading from the removed tour/tutorial system. +const HELP_STATE_KEY = "onboarding:tourProgress"; -const EMPTY_TOUR_ENTRY: OnboardingTourEntry = { - completedAt: null, - dismissedAt: null, - lastStepIndex: 0, -}; - -const EMPTY_VARIANT_ENTRY: OnboardingTourVariantEntry = { - completedAt: null, - dismissedAt: null, - lastStepIndex: 0, -}; - -function emptyVariantEntry(): OnboardingTourVariantEntry { - return { ...EMPTY_VARIANT_ENTRY }; -} - -function emptyTourEntryV2(): OnboardingTourEntryV2 { - return { - full: emptyVariantEntry(), - highlights: emptyVariantEntry(), - }; -} - -function emptyTutorialState(): OnboardingTutorialState { - return { - completedAt: null, - dismissedAt: null, - silenced: false, - inProgress: false, - lastActIndex: 0, - ctxSnapshot: {}, - }; -} - -function emptyTourProgress(): OnboardingTourProgress { +function emptyHelpState(): OnboardingHelpState { return { - wizardCompletedAt: null, - wizardDismissedAt: null, - tours: {}, - tourVariants: {}, - tutorial: emptyTutorialState(), glossaryTermsSeen: [], }; } -function normalizeTourEntry(raw: unknown): OnboardingTourEntry { - if (!raw || typeof raw !== "object") return { ...EMPTY_TOUR_ENTRY }; - const r = raw as Partial; - return { - completedAt: typeof r.completedAt === "string" ? r.completedAt : null, - dismissedAt: typeof r.dismissedAt === "string" ? r.dismissedAt : null, - lastStepIndex: - typeof r.lastStepIndex === "number" && Number.isFinite(r.lastStepIndex) && r.lastStepIndex >= 0 - ? Math.floor(r.lastStepIndex) - : 0, - }; -} - -function normalizeVariantEntry(raw: unknown): OnboardingTourVariantEntry { - if (!raw || typeof raw !== "object") return emptyVariantEntry(); - const r = raw as Partial; - return { - completedAt: typeof r.completedAt === "string" ? r.completedAt : null, - dismissedAt: typeof r.dismissedAt === "string" ? r.dismissedAt : null, - lastStepIndex: - typeof r.lastStepIndex === "number" && Number.isFinite(r.lastStepIndex) && r.lastStepIndex >= 0 - ? Math.floor(r.lastStepIndex) - : 0, - }; -} - -/** - * Accepts either the legacy flat `OnboardingTourEntry` shape or the new - * `{ full, highlights }` variant shape. Legacy entries are treated as the - * "full" variant with defaults for "highlights". - */ -function normalizeTourEntryV2(raw: unknown): OnboardingTourEntryV2 { - if (!raw || typeof raw !== "object") return emptyTourEntryV2(); - const r = raw as Record; - const hasVariantShape = - ("full" in r && typeof r.full === "object") || - ("highlights" in r && typeof r.highlights === "object"); - if (hasVariantShape) { - return { - full: normalizeVariantEntry(r.full), - highlights: normalizeVariantEntry(r.highlights), - }; - } - // Legacy flat entry — map onto the "full" variant. - return { - full: normalizeVariantEntry(r), - highlights: emptyVariantEntry(), - }; -} - -function normalizeTutorialState(raw: unknown): OnboardingTutorialState { - if (!raw || typeof raw !== "object") return emptyTutorialState(); - const r = raw as Partial; - const snapshot = - r.ctxSnapshot && typeof r.ctxSnapshot === "object" && !Array.isArray(r.ctxSnapshot) - ? (r.ctxSnapshot as Record) - : {}; - return { - completedAt: typeof r.completedAt === "string" ? r.completedAt : null, - dismissedAt: typeof r.dismissedAt === "string" ? r.dismissedAt : null, - silenced: typeof r.silenced === "boolean" ? r.silenced : false, - inProgress: typeof r.inProgress === "boolean" ? r.inProgress : false, - lastActIndex: - typeof r.lastActIndex === "number" && Number.isFinite(r.lastActIndex) && r.lastActIndex >= 0 - ? Math.floor(r.lastActIndex) - : 0, - ctxSnapshot: { ...snapshot }, - }; -} - -function normalizeTourProgress(raw: unknown): OnboardingTourProgress { - if (!raw || typeof raw !== "object") return emptyTourProgress(); - const r = raw as Partial & { - tourVariants?: Record; - tutorial?: unknown; - }; - const tours: Record = {}; - if (r.tours && typeof r.tours === "object") { - for (const [id, value] of Object.entries(r.tours)) { - if (!id) continue; - tours[id] = normalizeTourEntry(value); - } - } - const tourVariants: Record = {}; - if (r.tourVariants && typeof r.tourVariants === "object") { - for (const [id, value] of Object.entries(r.tourVariants)) { - if (!id) continue; - tourVariants[id] = normalizeTourEntryV2(value); - } - } - // Back-fill: any tour touched via the legacy flat shape is mirrored into - // the "full" variant so variant-aware readers see a consistent view. - for (const [id, legacy] of Object.entries(tours)) { - const existing = tourVariants[id]; - if (!existing) { - tourVariants[id] = { - full: { ...legacy }, - highlights: emptyVariantEntry(), - }; - continue; - } - // Preserve existing variant data but ensure "full" at minimum reflects legacy. - const fullTouched = - existing.full.completedAt !== null || - existing.full.dismissedAt !== null || - existing.full.lastStepIndex > 0; - if (!fullTouched) { - tourVariants[id] = { ...existing, full: { ...legacy } }; - } - } +function normalizeHelpState(raw: unknown): OnboardingHelpState { + if (!raw || typeof raw !== "object") return emptyHelpState(); + const r = raw as Partial; const seen = Array.isArray(r.glossaryTermsSeen) ? Array.from( new Set( - r.glossaryTermsSeen.filter((v): v is string => typeof v === "string" && v.length > 0), + r.glossaryTermsSeen + .filter((v): v is string => typeof v === "string") + .map((v) => v.trim()) + .filter((v) => v.length > 0), ), ) : []; return { - wizardCompletedAt: typeof r.wizardCompletedAt === "string" ? r.wizardCompletedAt : null, - wizardDismissedAt: typeof r.wizardDismissedAt === "string" ? r.wizardDismissedAt : null, - tours, - tourVariants, - tutorial: normalizeTutorialState(r.tutorial), glossaryTermsSeen: seen, }; } @@ -232,198 +81,27 @@ export function createOnboardingService(args: { return status; }; - type NormalizedTourProgress = OnboardingTourProgress & { - tourVariants: Record; - tutorial: OnboardingTutorialState; + const getHelpState = (): OnboardingHelpState => { + const stored = db.getJson(HELP_STATE_KEY); + return normalizeHelpState(stored); }; - const getTourProgress = (): NormalizedTourProgress => { - const stored = db.getJson(TOUR_PROGRESS_KEY); - return normalizeTourProgress(stored) as NormalizedTourProgress; - }; - - const writeTourProgress = (next: OnboardingTourProgress): OnboardingTourProgress => { - db.setJson(TOUR_PROGRESS_KEY, next); + const writeHelpState = (next: OnboardingHelpState): OnboardingHelpState => { + db.setJson(HELP_STATE_KEY, next); return next; }; - const markWizardCompleted = (): OnboardingTourProgress => { - const current = getTourProgress(); - return writeTourProgress({ - ...current, - wizardCompletedAt: nowIso(), - wizardDismissedAt: null, - }); - }; - - const markWizardDismissed = (): OnboardingTourProgress => { - const current = getTourProgress(); - return writeTourProgress({ - ...current, - wizardDismissedAt: nowIso(), - }); - }; - - const DEFAULT_VARIANT: OnboardingTourVariant = "full"; - - const coerceVariant = (variant: OnboardingTourVariant | undefined): OnboardingTourVariant => - variant === "highlights" ? "highlights" : "full"; - - const updateTourVariantEntry = ( - tourId: string, - variant: OnboardingTourVariant, - patch: Partial, - ): OnboardingTourProgress => { - const id = tourId.trim(); - if (!id) return getTourProgress(); - const v = coerceVariant(variant); - const current = getTourProgress(); - const existing = current.tourVariants[id] ?? emptyTourEntryV2(); - const nextVariantEntry: OnboardingTourVariantEntry = { ...existing[v], ...patch }; - const nextV2: OnboardingTourEntryV2 = { ...existing, [v]: nextVariantEntry }; - // Keep the legacy flat `tours[id]` mirrored to the "full" variant so - // existing renderer code continues to work until callers migrate. - const nextTours = - v === "full" - ? { ...current.tours, [id]: { ...nextVariantEntry } } - : current.tours; - return writeTourProgress({ - ...current, - tours: nextTours, - tourVariants: { - ...current.tourVariants, - [id]: nextV2, - }, - }); - }; - - const markTourCompleted = ( - tourId: string, - variant: OnboardingTourVariant = DEFAULT_VARIANT, - ): OnboardingTourProgress => - updateTourVariantEntry(tourId, variant, { completedAt: nowIso(), dismissedAt: null }); - - const markTourDismissed = ( - tourId: string, - variant: OnboardingTourVariant = DEFAULT_VARIANT, - ): OnboardingTourProgress => - updateTourVariantEntry(tourId, variant, { dismissedAt: nowIso() }); - - const updateTourStep = ( - tourId: string, - indexOrVariant: number | OnboardingTourVariant, - maybeIndex?: number, - ): OnboardingTourProgress => { - // Backward-compat overload: (tourId, index) — legacy signature, writes to "full". - // New overload: (tourId, variant, index) — variant-aware. - const usingLegacySignature = typeof indexOrVariant === "number"; - const variant = usingLegacySignature ? DEFAULT_VARIANT : coerceVariant(indexOrVariant); - const rawIndex = usingLegacySignature ? indexOrVariant : (maybeIndex ?? 0); - const safeIndex = Number.isFinite(rawIndex) && rawIndex >= 0 ? Math.floor(rawIndex) : 0; - return updateTourVariantEntry(tourId, variant, { lastStepIndex: safeIndex }); - }; - - // Tutorial (Round 2) ----------------------------------------------------- - - const TUTORIAL_ACT_MIN = 0; - const TUTORIAL_ACT_MAX = 12; - - const writeTutorial = ( - patch: Partial, - ): OnboardingTourProgress => { - const current = getTourProgress(); - const base = current.tutorial; - const nextTutorial: OnboardingTutorialState = { - completedAt: patch.completedAt !== undefined ? patch.completedAt : base.completedAt, - dismissedAt: patch.dismissedAt !== undefined ? patch.dismissedAt : base.dismissedAt, - silenced: patch.silenced !== undefined ? patch.silenced : base.silenced, - inProgress: patch.inProgress !== undefined ? patch.inProgress : base.inProgress, - lastActIndex: patch.lastActIndex !== undefined ? patch.lastActIndex : base.lastActIndex, - ctxSnapshot: { ...(patch.ctxSnapshot ?? base.ctxSnapshot) }, - }; - return writeTourProgress({ ...current, tutorial: nextTutorial }); - }; - - const markTutorialStarted = (): OnboardingTourProgress => - writeTutorial({ inProgress: true, dismissedAt: null }); - - const markTutorialDismissed = (permanent: boolean): OnboardingTourProgress => - writeTutorial({ - dismissedAt: nowIso(), - inProgress: false, - ...(permanent ? { silenced: true } : {}), - }); - - const markTutorialCompleted = (): OnboardingTourProgress => - writeTutorial({ - completedAt: nowIso(), - dismissedAt: null, - inProgress: false, - }); - - const updateTutorialAct = ( - actIndex: number, - ctxSnapshot?: Record, - ): OnboardingTourProgress => { - const safeIndex = - typeof actIndex === "number" && Number.isFinite(actIndex) - ? Math.max(TUTORIAL_ACT_MIN, Math.min(TUTORIAL_ACT_MAX, Math.floor(actIndex))) - : TUTORIAL_ACT_MIN; - const snapshot = - ctxSnapshot && typeof ctxSnapshot === "object" && !Array.isArray(ctxSnapshot) - ? ctxSnapshot - : undefined; - const patch: Partial = { lastActIndex: safeIndex, inProgress: true }; - if (snapshot) patch.ctxSnapshot = snapshot; - return writeTutorial(patch); - }; - - const setTutorialSilenced = (silenced: boolean): OnboardingTourProgress => - writeTutorial({ silenced: Boolean(silenced) }); - - const clearTutorialSessionDismissal = (): OnboardingTourProgress => - writeTutorial({ dismissedAt: null }); - - const shouldPromptTutorial = (): boolean => { - const { tutorial } = getTourProgress(); - if (tutorial.completedAt) return false; - if (tutorial.silenced) return false; - if (tutorial.dismissedAt) return false; - return true; - }; - - const markGlossaryTermSeen = (termId: string): OnboardingTourProgress => { + const markGlossaryTermSeen = (termId: string): OnboardingHelpState => { const id = termId.trim(); - if (!id) return getTourProgress(); - const current = getTourProgress(); + if (!id) return getHelpState(); + const current = getHelpState(); if (current.glossaryTermsSeen.includes(id)) return current; - return writeTourProgress({ + return writeHelpState({ ...current, glossaryTermsSeen: [...current.glossaryTermsSeen, id], }); }; - const resetTourProgress = (tourId?: string): OnboardingTourProgress => { - if (tourId === undefined) { - return writeTourProgress(emptyTourProgress()); - } - const id = tourId.trim(); - if (!id) return getTourProgress(); - const current = getTourProgress(); - const hasLegacy = id in current.tours; - const hasVariant = id in current.tourVariants; - if (!hasLegacy && !hasVariant) return current; - const nextTours = { ...current.tours }; - delete nextTours[id]; - const nextVariants = { ...current.tourVariants }; - delete nextVariants[id]; - return writeTourProgress({ - ...current, - tours: nextTours, - tourVariants: nextVariants, - }); - }; - const detectDefaults = async (): Promise => { const indicators: OnboardingDetectionIndicator[] = []; const projectTypes: string[] = []; @@ -534,23 +212,8 @@ export function createOnboardingService(args: { setDismissed, detectDefaults, detectExistingLanes, - getTourProgress, - markWizardCompleted, - markWizardDismissed, - markTourCompleted, - markTourDismissed, - updateTourStep, + getHelpState, markGlossaryTermSeen, - resetTourProgress, - - // Tutorial (Round 2) - markTutorialStarted, - markTutorialDismissed, - markTutorialCompleted, - updateTutorialAct, - setTutorialSilenced, - clearTutorialSessionDismissal, - shouldPromptTutorial, // Convenience hook for UI flows: apply suggested config as local draft. applySuggestedConfig: async (suggestedConfig: ProjectConfigFile): Promise => { diff --git a/apps/desktop/src/main/services/state/globalState.ts b/apps/desktop/src/main/services/state/globalState.ts index 6963d6534..696b4de1f 100644 --- a/apps/desktop/src/main/services/state/globalState.ts +++ b/apps/desktop/src/main/services/state/globalState.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { randomUUID } from "node:crypto"; -import type { OpenProjectBinding, RecentlyInstalledUpdate } from "../../../shared/types"; +import type { AppWelcomeVideoState, OpenProjectBinding, RecentlyInstalledUpdate } from "../../../shared/types"; export type RecentProjectRemote = { targetId: string; @@ -48,6 +48,7 @@ export type GlobalState = { recentProjects?: RecentProject[]; pendingInstallUpdate?: PendingInstallUpdate; recentlyInstalledUpdate?: RecentlyInstalledUpdate; + welcomeVideo?: AppWelcomeVideoState; }; export function readGlobalState(filePath: string): GlobalState { diff --git a/apps/desktop/src/main/services/usage/usageTrackingService.test.ts b/apps/desktop/src/main/services/usage/usageTrackingService.test.ts index 558221557..32fc46e7d 100644 --- a/apps/desktop/src/main/services/usage/usageTrackingService.test.ts +++ b/apps/desktop/src/main/services/usage/usageTrackingService.test.ts @@ -1520,6 +1520,7 @@ describe("scanCodexLogs", () => { describe("scanOpenClawLogs", () => { it("parses assistant usage from OpenClaw session logs", async () => { const tmpDir = makeTmpDir(); + const timestamp = new Date(Date.now() - 60_000).toISOString(); try { const sessionDir = path.join(tmpDir, ".openclaw", "agents", "director", "sessions"); fs.mkdirSync(sessionDir, { recursive: true }); @@ -1529,17 +1530,17 @@ describe("scanOpenClawLogs", () => { JSON.stringify({ type: "session", id: "session-1", - timestamp: "2026-05-29T12:00:00.000Z", + timestamp, }), JSON.stringify({ type: "model_change", modelId: "gpt-5.4", - timestamp: "2026-05-29T12:00:01.000Z", + timestamp, }), JSON.stringify({ type: "message", id: "message-1", - timestamp: "2026-05-29T12:00:02.000Z", + timestamp, message: { role: "assistant", model: "gpt-5.4", @@ -1787,6 +1788,7 @@ describe("scanGeminiLogs", () => { describe("scanOpenCodeLogs", () => { it("parses assistant token rows from OpenCode SQLite databases", async () => { const tmpDir = makeTmpDir(); + const timestamp = Date.now() - 60_000; const { DatabaseSync } = requireForTest("node:sqlite") as { DatabaseSync: new (dbPath: string, options?: Record) => any }; try { const dbPath = path.join(tmpDir, "opencode.db"); @@ -1802,7 +1804,7 @@ describe("scanOpenCodeLogs", () => { db.prepare("insert into message values (?, ?, ?, ?)").run( "msg-1", "session-1", - Date.parse("2026-05-29T12:00:00.000Z"), + timestamp, JSON.stringify({ role: "assistant", modelID: "openai/gpt-5.4", diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index f2c534a0b..795cc704a 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -12,6 +12,7 @@ import type { AdoptAttachedLaneArgs, UnregisteredLaneCandidate, AppInfo, + AppWelcomeVideoState, AppResourceUsageSnapshot, LatestReleaseInfo, AppNavigationRequest, @@ -267,9 +268,8 @@ import type { KeybindingsSnapshot, OnboardingDetectionResult, OnboardingExistingLaneCandidate, + OnboardingHelpState, OnboardingStatus, - OnboardingTourProgress, - OnboardingTourVariant, GitActionResult, GitBranchSummary, GitCheckoutBranchArgs, @@ -646,6 +646,10 @@ declare global { binding: OpenProjectBinding | null; openProjectTabs: ProjectInfo[]; }>; + getWelcomeVideoState: () => Promise; + markWelcomeVideoSeen: ( + reason: "completed" | "dismissed", + ) => Promise; setWindowProjectTabs: ( rootPaths: string[], ) => Promise<{ openProjectTabs: ProjectInfo[] }>; @@ -932,44 +936,9 @@ declare global { detectExistingLanes: () => Promise; setDismissed: (dismissed: boolean) => Promise; complete: () => Promise; - getTourProgress: () => Promise; - markWizardCompleted: () => Promise; - markWizardDismissed: () => Promise; - markTourCompleted: (tourId: string) => Promise; - markTourDismissed: (tourId: string) => Promise; - updateTourStep: ( - tourId: string, - index: number, - ) => Promise; markGlossaryTermSeen: ( termId: string, - ) => Promise; - resetTourProgress: (tourId?: string) => Promise; - markTourCompletedVariant: ( - tourId: string, - variant: OnboardingTourVariant, - ) => Promise; - markTourDismissedVariant: ( - tourId: string, - variant: OnboardingTourVariant, - ) => Promise; - updateTourStepVariant: ( - tourId: string, - variant: OnboardingTourVariant, - index: number, - ) => Promise; - tutorial: { - start: () => Promise; - dismiss: (permanent: boolean) => Promise; - complete: () => Promise; - updateAct: ( - actIndex: number, - ctxSnapshot?: Record, - ) => Promise; - setSilenced: (silenced: boolean) => Promise; - clearSessionDismissal: () => Promise; - shouldPrompt: () => Promise; - }; + ) => Promise; }; automations: { list: () => Promise; diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index 94b80a705..8cf79098a 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -83,6 +83,12 @@ describe("preload OAuth bridge", () => { project, { rootPath: "/repo/b", displayName: "B", baseRef: "main" }, ]; + const welcomeVideoState = { + videoId: "64E0pViEiB8", + version: 1, + completedAt: null, + dismissedAt: null, + }; const invoke = vi.fn(async (channel: string, _payload?: unknown) => { if (channel === IPC.appGetWindowSession) { return { windowId: 7, project, binding: null, openProjectTabs }; @@ -90,6 +96,12 @@ describe("preload OAuth bridge", () => { if (channel === IPC.appSetWindowProjectTabs) { return { openProjectTabs: [openProjectTabs[1]] }; } + if (channel === IPC.appGetWelcomeVideoState) { + return welcomeVideoState; + } + if (channel === IPC.appMarkWelcomeVideoSeen) { + return { ...welcomeVideoState, completedAt: "2026-06-28T12:00:00.000Z" }; + } return undefined; }); const on = vi.fn(); @@ -121,7 +133,13 @@ describe("preload OAuth bridge", () => { await expect(bridge.app.setWindowProjectTabs(["/repo/b"])).resolves.toEqual({ openProjectTabs: [openProjectTabs[1]], }); + await expect(bridge.app.getWelcomeVideoState()).resolves.toEqual(welcomeVideoState); + await expect(bridge.app.markWelcomeVideoSeen("completed")).resolves.toMatchObject({ + completedAt: "2026-06-28T12:00:00.000Z", + }); expect(invoke).toHaveBeenCalledWith(IPC.appSetWindowProjectTabs, { rootPaths: ["/repo/b"] }); + expect(invoke).toHaveBeenCalledWith(IPC.appGetWelcomeVideoState); + expect(invoke).toHaveBeenCalledWith(IPC.appMarkWelcomeVideoSeen, { reason: "completed" }); }); it("exposes review IPC methods and cleans up listeners", async () => { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 0f6cdbe11..00eeb50a6 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -19,6 +19,7 @@ import type { AdoptAttachedLaneArgs, UnregisteredLaneCandidate, AppInfo, + AppWelcomeVideoState, AppResourceUsageSnapshot, LatestReleaseInfo, AppNavigationRequest, @@ -357,9 +358,8 @@ import type { KeybindingsSnapshot, OnboardingDetectionResult, OnboardingExistingLaneCandidate, + OnboardingHelpState, OnboardingStatus, - OnboardingTourProgress, - OnboardingTourVariant, LaneLinearIssue, LaneListSnapshot, LaneSummary, @@ -3131,6 +3131,12 @@ contextBridge.exposeInMainWorld("ade", { rememberProjectBinding(session.binding); return { ...session, openProjectTabs: session.openProjectTabs ?? [] }; }, + getWelcomeVideoState: async (): Promise => + ipcRenderer.invoke(IPC.appGetWelcomeVideoState), + markWelcomeVideoSeen: async ( + reason: "completed" | "dismissed", + ): Promise => + ipcRenderer.invoke(IPC.appMarkWelcomeVideoSeen, { reason }), setWindowProjectTabs: async ( rootPaths: string[], ): Promise<{ openProjectTabs: ProjectInfo[] }> => @@ -3944,50 +3950,9 @@ contextBridge.exposeInMainWorld("ade", { callProjectRuntimeActionOr("onboarding", "complete", {}, () => ipcRenderer.invoke(IPC.onboardingComplete), ), - getTourProgress: async (): Promise => - callProjectRuntimeActionOr("onboarding", "getTourProgress", {}, () => - ipcRenderer.invoke(IPC.onboardingGetTourProgress), - ), - markWizardCompleted: async (): Promise => - callProjectRuntimeActionOr("onboarding", "markWizardCompleted", {}, () => - ipcRenderer.invoke(IPC.onboardingMarkWizardCompleted), - ), - markWizardDismissed: async (): Promise => - callProjectRuntimeActionOr("onboarding", "markWizardDismissed", {}, () => - ipcRenderer.invoke(IPC.onboardingMarkWizardDismissed), - ), - markTourCompleted: async ( - tourId: string, - ): Promise => - callProjectRuntimeActionOr( - "onboarding", - "markTourCompleted", - { arg: tourId }, - () => ipcRenderer.invoke(IPC.onboardingMarkTourCompleted, { tourId }), - ), - markTourDismissed: async ( - tourId: string, - ): Promise => - callProjectRuntimeActionOr( - "onboarding", - "markTourDismissed", - { arg: tourId }, - () => ipcRenderer.invoke(IPC.onboardingMarkTourDismissed, { tourId }), - ), - updateTourStep: async ( - tourId: string, - index: number, - ): Promise => - callProjectRuntimeActionOr( - "onboarding", - "updateTourStep", - { argsList: [tourId, index] }, - () => - ipcRenderer.invoke(IPC.onboardingUpdateTourStep, { tourId, index }), - ), markGlossaryTermSeen: async ( termId: string, - ): Promise => + ): Promise => callProjectRuntimeActionOr( "onboarding", "markGlossaryTermSeen", @@ -3995,119 +3960,6 @@ contextBridge.exposeInMainWorld("ade", { () => ipcRenderer.invoke(IPC.onboardingMarkGlossaryTermSeen, { termId }), ), - resetTourProgress: async ( - tourId?: string, - ): Promise => - callProjectRuntimeActionOr( - "onboarding", - "resetTourProgress", - { arg: tourId }, - () => ipcRenderer.invoke(IPC.onboardingResetTourProgress, { tourId }), - ), - markTourCompletedVariant: async ( - tourId: string, - variant: OnboardingTourVariant, - ): Promise => - callProjectRuntimeActionOr( - "onboarding", - "markTourCompleted", - { argsList: [tourId, variant] }, - () => - ipcRenderer.invoke(IPC.onboardingMarkTourCompletedVariant, { - tourId, - variant, - }), - ), - markTourDismissedVariant: async ( - tourId: string, - variant: OnboardingTourVariant, - ): Promise => - callProjectRuntimeActionOr( - "onboarding", - "markTourDismissed", - { argsList: [tourId, variant] }, - () => - ipcRenderer.invoke(IPC.onboardingMarkTourDismissedVariant, { - tourId, - variant, - }), - ), - updateTourStepVariant: async ( - tourId: string, - variant: OnboardingTourVariant, - index: number, - ): Promise => - callProjectRuntimeActionOr( - "onboarding", - "updateTourStep", - { argsList: [tourId, index, variant] }, - () => - ipcRenderer.invoke(IPC.onboardingUpdateTourStepVariant, { - tourId, - variant, - index, - }), - ), - tutorial: { - start: async (): Promise => - callProjectRuntimeActionOr( - "onboarding", - "markTutorialStarted", - {}, - () => ipcRenderer.invoke(IPC.onboardingTutorialStart), - ), - dismiss: async (permanent: boolean): Promise => - callProjectRuntimeActionOr( - "onboarding", - "markTutorialDismissed", - { arg: permanent }, - () => - ipcRenderer.invoke(IPC.onboardingTutorialDismiss, { permanent }), - ), - complete: async (): Promise => - callProjectRuntimeActionOr( - "onboarding", - "markTutorialCompleted", - {}, - () => ipcRenderer.invoke(IPC.onboardingTutorialComplete), - ), - updateAct: async ( - actIndex: number, - ctxSnapshot?: Record, - ): Promise => - callProjectRuntimeActionOr( - "onboarding", - "updateTutorialAct", - { argsList: [actIndex, ctxSnapshot] }, - () => - ipcRenderer.invoke(IPC.onboardingTutorialUpdateAct, { - actIndex, - ctxSnapshot, - }), - ), - setSilenced: async (silenced: boolean): Promise => - callProjectRuntimeActionOr( - "onboarding", - "setTutorialSilenced", - { arg: silenced }, - () => - ipcRenderer.invoke(IPC.onboardingTutorialSetSilenced, { silenced }), - ), - clearSessionDismissal: async (): Promise => - callProjectRuntimeActionOr( - "onboarding", - "clearTutorialSessionDismissal", - {}, - () => ipcRenderer.invoke(IPC.onboardingTutorialClearSessionDismissal), - ), - shouldPrompt: async (): Promise => - callProjectRuntimeActionOr( - "onboarding", - "shouldPromptTutorial", - {}, - () => ipcRenderer.invoke(IPC.onboardingTutorialShouldPrompt), - ), - }, }, automations: { list: async (): Promise => diff --git a/apps/desktop/src/renderer/assets/onboarding/README.md b/apps/desktop/src/renderer/assets/onboarding/README.md deleted file mode 100644 index 2ac0e3185..000000000 --- a/apps/desktop/src/renderer/assets/onboarding/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# Onboarding assets - -Placeholder SVG illustrations used by `TourIllustration` in hero steps. Each tab's flagship Full tour hero references one of these files. - -Naming: `{topic}-hero.svg`. Replace any of these with Lottie JSON (same filename, `.json`) and swap `kind: "lottie"` in the tour step — the `TourIllustration` primitive supports both. - -Uses CSS variables so they track the app's theme. Safe to replace with designed artwork later. diff --git a/apps/desktop/src/renderer/assets/onboarding/automations-hero.svg b/apps/desktop/src/renderer/assets/onboarding/automations-hero.svg deleted file mode 100644 index 58222b824..000000000 --- a/apps/desktop/src/renderer/assets/onboarding/automations-hero.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - trigger - action - diff --git a/apps/desktop/src/renderer/assets/onboarding/cto-hero.svg b/apps/desktop/src/renderer/assets/onboarding/cto-hero.svg deleted file mode 100644 index a3cbb6c6d..000000000 --- a/apps/desktop/src/renderer/assets/onboarding/cto-hero.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - CTO - - - - - - - - - diff --git a/apps/desktop/src/renderer/assets/onboarding/files-hero.svg b/apps/desktop/src/renderer/assets/onboarding/files-hero.svg deleted file mode 100644 index 66afb8040..000000000 --- a/apps/desktop/src/renderer/assets/onboarding/files-hero.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/apps/desktop/src/renderer/assets/onboarding/ghost-cursor.svg b/apps/desktop/src/renderer/assets/onboarding/ghost-cursor.svg deleted file mode 100644 index d32b1ce13..000000000 --- a/apps/desktop/src/renderer/assets/onboarding/ghost-cursor.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/desktop/src/renderer/assets/onboarding/graph-hero.svg b/apps/desktop/src/renderer/assets/onboarding/graph-hero.svg deleted file mode 100644 index 1f2e8e82c..000000000 --- a/apps/desktop/src/renderer/assets/onboarding/graph-hero.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/apps/desktop/src/renderer/assets/onboarding/history-hero.svg b/apps/desktop/src/renderer/assets/onboarding/history-hero.svg deleted file mode 100644 index dbe2ddaae..000000000 --- a/apps/desktop/src/renderer/assets/onboarding/history-hero.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - lane - commit - push - now - diff --git a/apps/desktop/src/renderer/assets/onboarding/lanes-hero.svg b/apps/desktop/src/renderer/assets/onboarding/lanes-hero.svg deleted file mode 100644 index 360a831c9..000000000 --- a/apps/desktop/src/renderer/assets/onboarding/lanes-hero.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - primary - diff --git a/apps/desktop/src/renderer/assets/onboarding/prs-hero.svg b/apps/desktop/src/renderer/assets/onboarding/prs-hero.svg deleted file mode 100644 index 0c210e270..000000000 --- a/apps/desktop/src/renderer/assets/onboarding/prs-hero.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - main - lane - diff --git a/apps/desktop/src/renderer/assets/onboarding/run-hero.svg b/apps/desktop/src/renderer/assets/onboarding/run-hero.svg deleted file mode 100644 index 50a077fd9..000000000 --- a/apps/desktop/src/renderer/assets/onboarding/run-hero.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/apps/desktop/src/renderer/assets/onboarding/settings-hero.svg b/apps/desktop/src/renderer/assets/onboarding/settings-hero.svg deleted file mode 100644 index 0a1652bcb..000000000 --- a/apps/desktop/src/renderer/assets/onboarding/settings-hero.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/apps/desktop/src/renderer/assets/onboarding/work-hero.svg b/apps/desktop/src/renderer/assets/onboarding/work-hero.svg deleted file mode 100644 index a9e956543..000000000 --- a/apps/desktop/src/renderer/assets/onboarding/work-hero.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 2eb9b4c1d..a23ab048e 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -33,6 +33,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { getDefaultModelDescriptor } from "../shared/modelRegistry"; +import { + ADE_WELCOME_VIDEO_ID, + ADE_WELCOME_VIDEO_VERSION, +} from "../shared/welcomeVideo"; import { attachBrowserRuntimeBridge } from "./browserRuntimeBridge"; const noop = () => () => {}; @@ -108,6 +112,43 @@ const MOCK_PROJECT = // ── Timestamps ──────────────────────────────────────────────── const now = new Date().toISOString(); +const WELCOME_VIDEO_STORAGE_KEY = "ade.browserMock.welcomeVideoState"; + +function readBrowserMockWelcomeVideoState() { + try { + const parsed = JSON.parse(window.localStorage.getItem(WELCOME_VIDEO_STORAGE_KEY) ?? "null") as { + videoId?: unknown; + version?: unknown; + completedAt?: unknown; + dismissedAt?: unknown; + } | null; + if (parsed?.videoId === ADE_WELCOME_VIDEO_ID && parsed.version === ADE_WELCOME_VIDEO_VERSION) { + return { + videoId: ADE_WELCOME_VIDEO_ID, + version: ADE_WELCOME_VIDEO_VERSION, + completedAt: typeof parsed.completedAt === "string" ? parsed.completedAt : null, + dismissedAt: typeof parsed.dismissedAt === "string" ? parsed.dismissedAt : null, + }; + } + } catch { + // Ignore malformed preview-only state. + } + return { + videoId: ADE_WELCOME_VIDEO_ID, + version: ADE_WELCOME_VIDEO_VERSION, + completedAt: null, + dismissedAt: null, + }; +} + +function writeBrowserMockWelcomeVideoState(state: ReturnType) { + try { + window.localStorage.setItem(WELCOME_VIDEO_STORAGE_KEY, JSON.stringify(state)); + } catch { + // Browser preview storage can be unavailable. + } +} + const MOCK_LINEAR_CONNECTION = { tokenStored: true, connected: true, @@ -2832,20 +2873,20 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { }, }; - const BROWSER_MOCK_TOUR_PROGRESS: any = { - wizardCompletedAt: now, - wizardDismissedAt: null, - tours: {}, - tourVariants: {}, + const BROWSER_MOCK_HELP_STATE: any = { glossaryTermsSeen: [], - tutorial: { - completedAt: null, - dismissedAt: null, - silenced: false, - inProgress: false, - lastActIndex: 0, - ctxSnapshot: {}, - }, + }; + + const markBrowserMockGlossaryTermSeen = (termId: unknown) => { + const id = typeof termId === "string" ? termId.trim() : ""; + if (!id) return BROWSER_MOCK_HELP_STATE; + if (!BROWSER_MOCK_HELP_STATE.glossaryTermsSeen.includes(id)) { + BROWSER_MOCK_HELP_STATE.glossaryTermsSeen = [ + ...BROWSER_MOCK_HELP_STATE.glossaryTermsSeen, + id, + ]; + } + return BROWSER_MOCK_HELP_STATE; }; const BROWSER_MOCK_USAGE_SNAPSHOT: any = { @@ -3161,6 +3202,17 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { }, openProjectTabs: [MOCK_PROJECT], }), + getWelcomeVideoState: async () => readBrowserMockWelcomeVideoState(), + markWelcomeVideoSeen: async (reason: "completed" | "dismissed" = "dismissed") => { + const current = readBrowserMockWelcomeVideoState(); + const next = { + ...current, + completedAt: reason === "completed" ? new Date().toISOString() : current.completedAt, + dismissedAt: reason === "completed" ? current.dismissedAt : new Date().toISOString(), + }; + writeBrowserMockWelcomeVideoState(next); + return next; + }, setWindowProjectTabs: resolved({ openProjectTabs: [MOCK_PROJECT] }), newWindow: resolved({ windowId: 2 }), openProjectInNewWindow: resolvedArg({ @@ -3527,27 +3579,8 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { completedAt: new Date().toISOString(), dismissedAt: null, }), - getTourProgress: resolved(BROWSER_MOCK_TOUR_PROGRESS), - markWizardCompleted: resolved(BROWSER_MOCK_TOUR_PROGRESS), - markWizardDismissed: resolved(BROWSER_MOCK_TOUR_PROGRESS), - markTourCompleted: resolvedArg(BROWSER_MOCK_TOUR_PROGRESS), - markTourDismissed: resolvedArg(BROWSER_MOCK_TOUR_PROGRESS), - updateTourStep: resolvedArg2(BROWSER_MOCK_TOUR_PROGRESS), - markGlossaryTermSeen: resolvedArg(BROWSER_MOCK_TOUR_PROGRESS), - resetTourProgress: resolvedArg(BROWSER_MOCK_TOUR_PROGRESS), - markTourCompletedVariant: resolvedArg2(BROWSER_MOCK_TOUR_PROGRESS), - markTourDismissedVariant: resolvedArg2(BROWSER_MOCK_TOUR_PROGRESS), - updateTourStepVariant: async (_a: any, _b: any, _c: any) => - BROWSER_MOCK_TOUR_PROGRESS, - tutorial: { - start: resolved(BROWSER_MOCK_TOUR_PROGRESS), - dismiss: resolvedArg(BROWSER_MOCK_TOUR_PROGRESS), - complete: resolved(BROWSER_MOCK_TOUR_PROGRESS), - updateAct: resolvedArg2(BROWSER_MOCK_TOUR_PROGRESS), - setSilenced: resolvedArg(BROWSER_MOCK_TOUR_PROGRESS), - clearSessionDismissal: resolved(BROWSER_MOCK_TOUR_PROGRESS), - shouldPrompt: resolved(false), - }, + markGlossaryTermSeen: (termId: string) => + Promise.resolve(markBrowserMockGlossaryTermSeen(termId)), }, automations: { list: resolved( diff --git a/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx b/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx index 8a85cb600..6dca2248a 100644 --- a/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx +++ b/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx @@ -219,7 +219,7 @@ describe("CommandPalette", () => { }); }); - it("closes the project browser when the tutorial steps back", async () => { + it("closes the project browser when requested", async () => { const onOpenChange = vi.fn(); browseDirectories.mockResolvedValue({ inputPath: "../", diff --git a/apps/desktop/src/renderer/components/app/CommandPalette.tsx b/apps/desktop/src/renderer/components/app/CommandPalette.tsx index c4a57c3d9..ca6a37a47 100644 --- a/apps/desktop/src/renderer/components/app/CommandPalette.tsx +++ b/apps/desktop/src/renderer/components/app/CommandPalette.tsx @@ -236,10 +236,6 @@ function rememberProjectIcon(rootPath: string, icon: ProjectIcon): void { } } -function isTourStepTarget(target: EventTarget | null): boolean { - return target instanceof Element && target.closest(".ade-tour-step") !== null; -} - function pathLabel(input: string | null | undefined): string { if (!input) return ""; const segments = input.split(/[\\/]/).filter(Boolean); @@ -1446,16 +1442,6 @@ export function CommandPalette({ event.preventDefault()} - onPointerDownOutside={(event) => { - if (isTourStepTarget(event.target)) { - event.preventDefault(); - } - }} - onInteractOutside={(event) => { - if (isTourStepTarget(event.target)) { - event.preventDefault(); - } - }} > { const run = screen.getByRole("link", { name: "Run" }); const review = screen.getByRole("link", { name: "Review" }); - expect(run.nextElementSibling).toBe(review); + const links = screen.getAllByRole("link"); + expect(links[links.indexOf(run) + 1]).toBe(review); }); it("opens the connected GitHub profile from the sidebar avatar", () => { diff --git a/apps/desktop/src/renderer/components/app/TabNav.tsx b/apps/desktop/src/renderer/components/app/TabNav.tsx index ef8c616fd..9c1f776c0 100644 --- a/apps/desktop/src/renderer/components/app/TabNav.tsx +++ b/apps/desktop/src/renderer/components/app/TabNav.tsx @@ -18,6 +18,8 @@ import { useAppStore } from "../../state/appStore"; import { revealLabel } from "../../lib/platform"; import { openExternalUrl } from "../../lib/openExternal"; import { logRendererDebugEvent } from "../../lib/debugLog"; +import { docs } from "../../onboarding/docsLinks"; +import { SmartTooltip, type SmartTooltipContent } from "../ui/SmartTooltip"; import type { GitHubStatus } from "../../../shared/types"; import { readStoredPrsRoute } from "../prs/prsRouteState"; @@ -37,6 +39,52 @@ const mainItems = [ const settingsItem = { to: "/settings", label: "Settings", icon: GearSix } as const; const SIDEBAR_ICON_SIZE = 20; const SIDEBAR_AVATAR_SIZE_CLASS = "h-5 w-5"; +const TAB_TOOLTIP_BY_PATH: Record> = { + "/work": { + description: "Chat with agents, launch CLI sessions, inspect shells, and use the right-side tool drawers.", + docUrl: docs.chatOverview, + }, + "/lanes": { + description: "Create, inspect, stack, rebase, and clean up isolated worktrees for parallel work.", + docUrl: docs.lanesOverview, + }, + "/files": { + description: "Browse lane workspaces, inspect file changes, and open project files without leaving ADE.", + docUrl: docs.filesEditor, + }, + "/prs": { + description: "Review ADE and GitHub pull requests, queues, integration proposals, checks, and merge readiness.", + docUrl: docs.prsOverview, + }, + "/project": { + description: "Run configured processes, previews, network routes, diagnostics, and project setup actions.", + docUrl: docs.projectHome, + }, + "/review": { + description: "Run and inspect AI review passes for the current project and PR workflow.", + docUrl: docs.prsOverview, + }, + "/cto": { + description: "Use the persistent project CTO, worker team, Linear workflows, and identity settings.", + docUrl: docs.ctoOverview, + }, + "/graph": { + description: "See lane topology, conflict risk, PR overlays, sync presence, and integration proposals on one canvas.", + docUrl: docs.workspaceGraph, + }, + "/history": { + description: "Explore commit history, lane operations, branch links, and recent project movement.", + docUrl: docs.historyOverview, + }, + "/automations": { + description: "Manage automation rules that trigger ADE work from events, schedules, and guarded actions.", + docUrl: docs.automationsOverview, + }, + "/settings": { + description: "Configure AI providers, GitHub, Linear, voice, lane behavior, templates, keybindings, and local project settings.", + docUrl: docs.settingsGeneral, + }, +}; function primaryTabPath(pathname: string): string { const match = mainItems.find((item) => pathname === item.to || pathname.startsWith(`${item.to}/`)); @@ -100,100 +148,130 @@ export function TabNav({ githubStatus }: { githubStatus?: GitHubStatus | null }) const isActive = !onWelcomeLanding && primaryTabPath(location.pathname) === it.to; const isActiveAllowed = !showWelcome && hasActiveProject; const navTarget = it.to === "/prs" ? readStoredPrsRoute(activeProjectRoot) ?? it.to : it.to; + const tooltipBase = TAB_TOOLTIP_BY_PATH[it.to]; + const tooltip: SmartTooltipContent = { + label: it.label, + description: tooltipBase?.description ?? `Open the ${it.label} tab.`, + effect: !hasActiveProject + ? "Open or create a project first." + : showWelcome + ? "Finish choosing a project before navigating." + : isActive + ? "Already viewing this tab." + : `Opens ${it.label}.`, + docUrl: tooltipBase?.docUrl, + }; if (!isActiveAllowed) { return ( -
- - - +
+ + + + - - {it.label} -
+ {it.label} +
+ ); } return ( - { - logRendererDebugEvent("renderer.tab_nav.click", { - projectRoot: activeProjectRoot, - from: location.pathname, - to: navTarget, - showWelcome, - hasActiveProject, - }); - }} - className={cn( - "ade-shell-sidebar-item group relative flex w-full items-center transition-colors duration-100", - )} + side="bottom" + content={tooltip} + wrapperClassName="w-full" + wrapperStyle={{ display: "flex" }} > - {/* Active indicator bar */} - {isActive && ( -
- )} - - {/* Fixed-width icon container - never moves during collapse */} - - - { + logRendererDebugEvent("renderer.tab_nav.click", { + projectRoot: activeProjectRoot, + from: location.pathname, + to: navTarget, + showWelcome, + hasActiveProject, + }); + }} + className={cn( + "ade-shell-sidebar-item group relative flex w-full items-center transition-colors duration-100", + )} + > + {/* Active indicator bar */} + {isActive && ( +
- {/* Terminal attention dot */} - {it.to === "/work" && terminalAttention.indicator !== "none" ? ( - + + - ) : null} - {it.to === "/automations" && isPackaged ? ( - - Soon - - ) : null} + {/* Terminal attention dot */} + {it.to === "/work" && terminalAttention.indicator !== "none" ? ( + + ) : null} + {it.to === "/automations" && isPackaged ? ( + + Soon + + ) : null} + - - {/* Label - opacity-animated separately from width transition */} - - {it.label} - - + {/* Label - opacity-animated separately from width transition */} + + {it.label} + + + ); }; diff --git a/apps/desktop/src/renderer/components/cto/CtoPage.tsx b/apps/desktop/src/renderer/components/cto/CtoPage.tsx index ced65716c..15653e864 100644 --- a/apps/desktop/src/renderer/components/cto/CtoPage.tsx +++ b/apps/desktop/src/renderer/components/cto/CtoPage.tsx @@ -85,18 +85,6 @@ export function CtoPage({ active = true }: { active?: boolean } = {}) { const [ctoIdentity, setCtoIdentity] = useState(null); const [sessionLogs, setSessionLogs] = useState([]); - useEffect(() => { - if (!active) return; - const onTourTab = (event: Event) => { - const tab = (event as CustomEvent).detail; - if (tab === "chat" || tab === "team" || tab === "workflows" || tab === "settings") { - setActiveTab(tab); - } - }; - window.addEventListener("ade:tour-cto-tab", onTourTab); - return () => window.removeEventListener("ade:tour-cto-tab", onTourTab); - }, [active]); - // Onboarding state const [onboardingState, setOnboardingState] = useState(null); const [showOnboarding, setShowOnboarding] = useState(false); @@ -596,7 +584,7 @@ export function CtoPage({ active = true }: { active?: boolean } = {}) { )} {/* Agent sidebar */} - {/* tour anchor — wraps AgentSidebar so data-tour attaches to a stable container */} + {/* Stable automation anchor — wraps AgentSidebar so data-tour attaches to a stable container */}
{ return selectActiveProjectRoot(appStore.getState()); }, [appStore]); - const activeTourId = useOnboardingStore((s) => s.activeTourId); - const suppressTourDistractions = activeTourId === "first-journey"; - const [activeLaneIds, setActiveLaneIds] = useState([]); const [pinnedLaneIds, setPinnedLaneIds] = useState>(new Set()); const [pulsingLaneId, setPulsingLaneId] = useState(null); @@ -729,7 +725,6 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { ); const selectableFilteredSet = useMemo(() => new Set(selectableFilteredLaneIds), [selectableFilteredLaneIds]); const visibleRebaseSuggestions = useMemo(() => { - if (suppressTourDistractions) return []; const laneIdSet = new Set(selectableFilteredLaneIds); return laneSnapshots .map((snapshot) => snapshot.rebaseSuggestion) @@ -739,9 +734,8 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { return laneIdSet.has(suggestion.laneId); }, ); - }, [laneSnapshots, selectableFilteredLaneIds, suppressTourDistractions]); + }, [laneSnapshots, selectableFilteredLaneIds]); const visibleAutoRebaseNeedsAttention = useMemo(() => { - if (suppressTourDistractions) return []; const laneIdSet = new Set(selectableFilteredLaneIds); return laneSnapshots .map((snapshot) => snapshot.autoRebaseStatus) @@ -751,7 +745,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { return laneIdSet.has(status.laneId) && status.state !== "autoRebased"; }, ); - }, [laneSnapshots, selectableFilteredLaneIds, suppressTourDistractions]); + }, [laneSnapshots, selectableFilteredLaneIds]); const activeWithPins = useMemo( () => mergeUnique( @@ -1951,42 +1945,6 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { setGridResetKey((k) => k + 1); }, [activeLaneIds, selectableFilteredLaneIds, lanesById, deletingLaneIds, selectLane, selectedLaneId, sortedSelectableLaneIds, visibleLaneIds]); - useEffect(() => { - const onTourEnded = (event: Event) => { - const detail = (event as CustomEvent<{ tourId?: unknown }>).detail; - if (detail?.tourId !== "first-journey") return; - void resetGridLayout(selectedLaneId); - }; - window.addEventListener("ade:tour-ended", onTourEnded); - return () => window.removeEventListener("ade:tour-ended", onTourEnded); - }, [resetGridLayout, selectedLaneId]); - - useEffect(() => { - const onTourFocusLane = (event: Event) => { - const detail = (event as CustomEvent<{ laneId?: unknown }>).detail; - const requestedLaneId = typeof detail?.laneId === "string" ? detail.laneId : null; - const requestedLane = requestedLaneId ? lanesById.get(requestedLaneId) ?? null : null; - const selectedLane = selectedLaneId ? lanesById.get(selectedLaneId) ?? null : null; - const fallbackLane = - selectedLane && selectedLane.laneType !== "primary" - ? selectedLane - : sortedLanes.find((lane) => lane.laneType !== "primary") ?? null; - const targetLane = requestedLane && requestedLane.laneType !== "primary" ? requestedLane : fallbackLane; - if (!targetLane) return; - setActiveLaneIds([targetLane.id]); - selectLane(targetLane.id); - void Promise.all([ - ...laneTilingLayoutIds(targetLane.id).flatMap((layoutKey) => [ - window.ade.layout.set(layoutKey, {}).catch(() => {}), - window.ade.tilingTree.set(layoutKey, {}).catch(() => {}), - ]), - window.ade.layout.set("lanes:columns:v1", {}).catch(() => {}), - ]).finally(() => setGridResetKey((k) => k + 1)); - }; - window.addEventListener("ade:tour-focus-lane", onTourFocusLane); - return () => window.removeEventListener("ade:tour-focus-lane", onTourFocusLane); - }, [lanesById, selectLane, selectedLaneId, sortedLanes]); - const requestRebaseScope = useCallback((laneId: string) => { const laneName = lanesById.get(laneId)?.name ?? laneId; return new Promise((resolve) => { @@ -3678,7 +3636,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { sessionCount: 0, }; const pendingInputCount = laneRuntime.pendingInputCount ?? 0; - const rebaseSuggestion = suppressTourDistractions ? null : laneSnapshot?.rebaseSuggestion ?? null; + const rebaseSuggestion = laneSnapshot?.rebaseSuggestion ?? null; const autoRebaseStatus = laneSnapshot?.autoRebaseStatus ?? null; const devicesOpen = lane.devicesOpen ?? []; const tabNumber = String(index + 1).padStart(2, "0"); @@ -4119,15 +4077,6 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { > Create Lane -
) diff --git a/apps/desktop/src/renderer/components/onboarding/DidYouKnow.tsx b/apps/desktop/src/renderer/components/onboarding/DidYouKnow.tsx index eba260edb..e90105d93 100644 --- a/apps/desktop/src/renderer/components/onboarding/DidYouKnow.tsx +++ b/apps/desktop/src/renderer/components/onboarding/DidYouKnow.tsx @@ -1,12 +1,12 @@ import React, { useEffect, useState } from "react"; import { createPortal } from "react-dom"; import { useAppStore } from "../../state/appStore"; -import { useOnboardingStore } from "../../state/onboardingStore"; import { openExternalUrl } from "../../lib/openExternal"; import { docs } from "../../onboarding/docsLinks"; type Hint = { id: string; + title?: string; body: string; docUrl?: string; }; @@ -16,17 +16,107 @@ const SESSION_KEY = "ade.didYouKnow.shown"; const DEFAULT_HINTS: Hint[] = [ { id: "lanes-parallel", - body: "Did you know? Each Lane is its own folder on disk, so changes in one Lane can't mess up another.", + body: "Each lane is its own worktree, so parallel agents can change files without trampling each other.", docUrl: docs.lanesOverview, }, { - id: "help-menu", - body: "Did you know? You can replay any tour anytime from the Help menu in the top-right.", - docUrl: docs.welcome, + id: "lane-runtime-isolation", + body: "Lane-scoped ports, proxy routes, OAuth callbacks, terminals, and health checks keep dev environments separated.", + docUrl: docs.lanesEnvironment, + }, + { + id: "lane-stacks", + body: "Stacked lanes know their parent chain, so ADE can surface rebase order and PR queue context instead of leaving it in your head.", + docUrl: docs.lanesStacks, + }, + { + id: "work-sessions", + body: "The Work tab keeps chats, CLI agents, and shells as resumable sessions tied to their lane.", + docUrl: docs.chatOverview, + }, + { + id: "chat-context", + body: "Chat context can include files, Linear issues, simulator selections, browser observations, and proof artifacts when those tools are active.", + docUrl: docs.chatContext, + }, + { + id: "proof-intentional", + body: "Proof is intentional: capture or attach the reviewer-visible screenshot, video, or trace you want to keep.", + docUrl: docs.proof, + }, + { + id: "proof-ownership", + body: "One proof artifact can be linked to a chat, lane, PR, or Linear issue without losing provenance.", + docUrl: docs.proof, + }, + { + id: "prs-ade-links", + body: "ADE PR creation returns both the GitHub URL and an ADE deep link, so handoffs can reopen the exact PR context.", + docUrl: docs.prsOverview, + }, + { + id: "prs-queues", + body: "The PRs tab models stacks, merge queues, integration proposals, and rebase needs from the same local git truth.", + docUrl: docs.prsQueues, + }, + { + id: "graph-projection", + body: "The workspace graph is not a separate data layer; it projects lane, PR, sync, conflict, and activity state into one canvas.", + docUrl: docs.workspaceGraph, + }, + { + id: "graph-risk", + body: "Graph risk mode turns pairwise file overlap into clickable conflict edges before a merge gets painful.", + docUrl: docs.workspaceGraph, + }, + { + id: "ios-simulator-owner", + body: "The iOS Simulator drawer is owned by the active runtime and chat/lane, so control and proof stay attached to the right work.", + docUrl: docs.iosSimulator, + }, + { + id: "simulator-preview", + body: "Preview Lab can resolve a selected simulator element back to SwiftUI preview targets when source context is available.", + docUrl: docs.iosSimulator, + }, + { + id: "sync-local-first", + body: "ADE sync is local-first and peer-to-peer: runtime state converges over WebSocket without a cloud dependency.", + docUrl: docs.syncMultiDevice, + }, + { + id: "remote-runtime", + body: "When a window is bound to a remote runtime, lanes, PR actions, proof storage, and simulator checks run on that remote machine.", + docUrl: docs.syncMultiDevice, + }, + { + id: "deeplinks", + body: "ADE links can target a lane, Work session, branch, PR, or Linear issue from desktop, iOS, ADE Code, or the web handoff page.", + docUrl: docs.deeplinks, + }, + { + id: "cto-identity", + body: "The CTO is a persistent project identity with continuity, workers, Linear dispatch, and an operator-only tool surface.", + docUrl: docs.ctoOverview, + }, + { + id: "workers", + body: "Workers are named project agents with roles, budgets, adapter settings, and optional Linear identities.", + docUrl: docs.ctoWorkers, + }, + { + id: "automations-guardrails", + body: "Automations dispatch real ADE work, so they rely on explicit triggers, guardrails, and the agent's own proof captures.", + docUrl: docs.automationsGuardrails, + }, + { + id: "settings-scope", + body: "Shared project settings live in `.ade/ade.yaml`; local machine-only overrides live in `.ade/local.yaml`.", + docUrl: docs.settingsGeneral, }, { id: "help-chip", - body: "Did you know? The small \"?\" next to a word opens a quick plain-English definition.", + body: "The small \"?\" next to a word opens a quick plain-English definition without interrupting your flow.", docUrl: docs.keyConcepts, }, ]; @@ -36,15 +126,12 @@ type DidYouKnowProps = { }; export function DidYouKnow({ hints }: DidYouKnowProps) { - const onboardingEnabled = useAppStore((s) => s.onboardingEnabled); const didYouKnowEnabled = useAppStore((s) => s.didYouKnowEnabled); - const activeTourId = useOnboardingStore((s) => s.activeTourId); const [dismissed, setDismissed] = useState(false); const [hint, setHint] = useState(null); useEffect(() => { - if (activeTourId) return; - if (!onboardingEnabled || !didYouKnowEnabled) return; + if (!didYouKnowEnabled) return; try { if (sessionStorage.getItem(SESSION_KEY) === "1") return; } catch { @@ -58,10 +145,9 @@ export function DidYouKnow({ hints }: DidYouKnowProps) { } catch { /* ignore */ } - }, [activeTourId, onboardingEnabled, didYouKnowEnabled, hints]); + }, [didYouKnowEnabled, hints]); - if (activeTourId) return null; - if (!onboardingEnabled || !didYouKnowEnabled) return null; + if (!didYouKnowEnabled) return null; if (!hint || dismissed) return null; if (typeof document === "undefined") return null; @@ -93,7 +179,7 @@ export function DidYouKnow({ hints }: DidYouKnowProps) { gap: 10, }} > -

- {hint.body} -

+
+ {hint.title ?? "Did you know?"} +
+
{hint.body}
+
+
+ +
+
+ }> + GitHub + + }> + Docs + + }> + Open video + + +
+ +
+