diff --git a/packages/client/src/clients/guide/client.ts b/packages/client/src/clients/guide/client.ts index 25aad07cf..dfe155fc8 100644 --- a/packages/client/src/clients/guide/client.ts +++ b/packages/client/src/clients/guide/client.ts @@ -211,6 +211,12 @@ const predicate = ( // If in debug mode with a forced guide key, bypass other filtering and always // return true for that guide only. This should always run AFTER checking the // filters but BEFORE checking archived status and location rules. + if ( + debug.focusedGuideKeys && + Object.keys(debug.focusedGuideKeys).length > 0 + ) { + return !!debug.focusedGuideKeys[guide.key]; + } if (debug.forcedGuideKey) { return debug.forcedGuideKey === guide.key; } @@ -563,7 +569,11 @@ export class KnockGuideClient { // Clear debug state from store this.store.setState((state) => ({ ...state, - debug: { forcedGuideKey: null, previewSessionId: null }, + debug: { + forcedGuideKey: null, + previewSessionId: null, + focusedGuideKeys: {}, + }, previewGuides: {}, // Clear preview guides when exiting debug mode })); @@ -591,6 +601,7 @@ export class KnockGuideClient { debug: { skipEngagementTracking: true, ignoreDisplayInterval: true, + focusedGuideKeys: {}, ...debugOpts, debugging: true, }, @@ -754,6 +765,10 @@ export class KnockGuideClient { return guide; } + // If focused while in debug mode, then we want to ignore the guide order + // and throttle settings and force render this guide. + const focusedInDebug = state.debug?.focusedGuideKeys?.[guide.key]; + const throttled = !opts.includeThrottled && checkStateIfThrottled(state); switch (this.stage.status) { @@ -770,6 +785,13 @@ export class KnockGuideClient { // we can re-resolve when the group stage closes. this.stage.ordered[index] = guide.key; + if (focusedInDebug) { + this.knock.log( + `[Guide] Focused to return \`${guide.key}\` (stage: ${formatGroupStage(this.stage)})`, + ); + return guide; + } + if (throttled) { this.knock.log(`[Guide] Throttling the selected guide: ${guide.key}`); return undefined; @@ -783,6 +805,13 @@ export class KnockGuideClient { } case "closed": { + if (focusedInDebug) { + this.knock.log( + `[Guide] Focused to return \`${guide.key}\` (stage: ${formatGroupStage(this.stage)})`, + ); + return guide; + } + if (throttled) { this.knock.log(`[Guide] Throttling the selected guide: ${guide.key}`); return undefined; @@ -1082,7 +1111,10 @@ export class KnockGuideClient { // Get the next unarchived step. getStep() { // If debugging this guide, return the first step regardless of archive status - if (self.store.state.debug?.forcedGuideKey === this.key) { + if ( + self.store.state.debug?.forcedGuideKey === this.key || + self.store.state.debug?.focusedGuideKeys?.[this.key] + ) { return this.steps[0]; } diff --git a/packages/client/src/clients/guide/types.ts b/packages/client/src/clients/guide/types.ts index 9e09efb93..d57ae5276 100644 --- a/packages/client/src/clients/guide/types.ts +++ b/packages/client/src/clients/guide/types.ts @@ -232,6 +232,7 @@ export type QueryStatus = { export type DebugState = { debugging?: boolean; forcedGuideKey?: string | null; + focusedGuideKeys?: Record; previewSessionId?: string | null; skipEngagementTracking?: boolean; ignoreDisplayInterval?: boolean; diff --git a/packages/client/test/clients/guide/guide.test.ts b/packages/client/test/clients/guide/guide.test.ts index 4bae5b050..8aa2ac166 100644 --- a/packages/client/test/clients/guide/guide.test.ts +++ b/packages/client/test/clients/guide/guide.test.ts @@ -2260,6 +2260,116 @@ describe("KnockGuideClient", () => { expect(result).toBeUndefined(); }); + test("returns an archived guide when focusedGuideKeys includes it", () => { + const archivedGuide = { + ...mockGuideThree, + steps: [ + { + ...mockStep, + message: { + ...mockStep.message, + archived_at: new Date().toISOString(), + }, + }, + ], + }; + + const stateWithArchivedGuide = { + guideGroups: [mockDefaultGroup], + guideGroupDisplayLogs: {}, + guides: { + ...mockGuides, + [mockGuideThree.key]: archivedGuide, + }, + ineligibleGuides: {}, + previewGuides: {}, + queries: {}, + location: undefined, + counter: 0, + debug: { + focusedGuideKeys: { [mockGuideThree.key]: true as const }, + }, + }; + + const client = new KnockGuideClient(mockKnock, channelId); + const result = client["_selectGuide"](stateWithArchivedGuide, { + key: mockGuideThree.key, + }); + + // Should return the focused guide even though it's archived + expect(result!.key).toBe("system_status"); + expect(result!.steps[0]!.message.archived_at).toBeTruthy(); + }); + + test("returns only focused guides when focusedGuideKeys is set", () => { + const stateWithGuides = { + guideGroups: [mockDefaultGroup], + guideGroupDisplayLogs: {}, + guides: mockGuides, + ineligibleGuides: {}, + previewGuides: {}, + queries: {}, + location: undefined, + counter: 0, + debug: { + focusedGuideKeys: { [mockGuideTwo.key]: true as const }, + }, + }; + + const client = new KnockGuideClient(mockKnock, channelId); + const result = client["_selectGuide"](stateWithGuides); + + // Should return the focused guide + expect(result!.key).toBe("feature_tour"); + }); + + test("doesn't return a guide not in focusedGuideKeys", () => { + const stateWithGuides = { + guideGroups: [mockDefaultGroup], + guideGroupDisplayLogs: {}, + guides: mockGuides, + ineligibleGuides: {}, + previewGuides: {}, + queries: {}, + location: undefined, + counter: 0, + debug: { + focusedGuideKeys: { [mockGuideTwo.key]: true as const }, + }, + }; + + const client = new KnockGuideClient(mockKnock, channelId); + const result = client["_selectGuide"](stateWithGuides, { + key: mockGuideOne.key, + }); + + // Guide one is not in focusedGuideKeys, so should not be returned + expect(result).toBeUndefined(); + }); + + test("falls through to normal filtering when focusedGuideKeys is empty object", () => { + const stateWithGuides = { + guideGroups: [mockDefaultGroup], + guideGroupDisplayLogs: {}, + guides: mockGuides, + ineligibleGuides: {}, + previewGuides: {}, + queries: {}, + location: undefined, + counter: 0, + debug: { + focusedGuideKeys: {}, + }, + }; + + const client = new KnockGuideClient(mockKnock, channelId); + const result = client["_selectGuide"](stateWithGuides); + + // Empty focusedGuideKeys should not filter — normal selection applies + expect(result).toBeDefined(); + expect(result!.key).toBe("feature_tour"); + }); + test("does not return a guide inside a throttle window ", () => { const stateWithGuides = { guideGroups: [ @@ -4217,6 +4327,152 @@ describe("KnockGuideClient", () => { expect(result).toBeDefined(); expect(result!.key).toBe("onboarding"); }); + + test("returns focused guide in closed stage even when throttled", () => { + const stateWithGuides = { + guideGroups: [throttleDefaultGroup], + guideGroupDisplayLogs: { + default: new Date().toISOString(), + }, + guides: { onboarding: mockGuide }, + ineligibleGuides: {}, + previewGuides: {}, + queries: {}, + location: undefined, + counter: 0, + debug: { + focusedGuideKeys: { onboarding: true as const }, + }, + }; + + const client = new KnockGuideClient(mockKnock, channelId); + + // Set up a closed stage with the guide resolved + client["stage"] = { + status: "closed", + ordered: ["onboarding"], + resolved: "onboarding", + results: {}, + timeoutId: null, + }; + + // Focused guides bypass throttle in closed stage + const result = client.selectGuide(stateWithGuides, { + key: "onboarding", + }); + expect(result).toBeDefined(); + expect(result!.key).toBe("onboarding"); + }); + + test("returns focused guide in patch stage even when throttled", () => { + const stateWithGuides = { + guideGroups: [throttleDefaultGroup], + guideGroupDisplayLogs: { + default: new Date().toISOString(), + }, + guides: { onboarding: mockGuide }, + ineligibleGuides: {}, + previewGuides: {}, + queries: {}, + location: undefined, + counter: 0, + debug: { + focusedGuideKeys: { onboarding: true as const }, + }, + }; + + const client = new KnockGuideClient(mockKnock, channelId); + + // Set up a closed stage then patch it + client["stage"] = { + status: "closed", + ordered: ["onboarding"], + resolved: "onboarding", + results: {}, + timeoutId: null, + }; + client["patchClosedGroupStage"](); + + expect(client.getStage()!.status).toBe("patch"); + + // Focused guides bypass throttle in patch stage + const result = client.selectGuide(stateWithGuides, { + key: "onboarding", + }); + expect(result).toBeDefined(); + expect(result!.key).toBe("onboarding"); + }); + + test("returns focused guide in patch stage even when not the resolved guide", () => { + const stateWithGuides = { + guideGroups: [throttleDefaultGroup], + guideGroupDisplayLogs: {}, + guides: { onboarding: mockGuide }, + ineligibleGuides: {}, + previewGuides: {}, + queries: {}, + location: undefined, + counter: 0, + debug: { + focusedGuideKeys: { onboarding: true as const }, + }, + }; + + const client = new KnockGuideClient(mockKnock, channelId); + + // Set up a closed stage where a DIFFERENT guide was resolved + client["stage"] = { + status: "closed", + ordered: ["onboarding"], + resolved: "some_other_guide", + results: {}, + timeoutId: null, + }; + client["patchClosedGroupStage"](); + + expect(client.getStage()!.status).toBe("patch"); + + // Focused guides bypass the resolved check in patch stage + const result = client.selectGuide(stateWithGuides, { + key: "onboarding", + }); + expect(result).toBeDefined(); + expect(result!.key).toBe("onboarding"); + }); + + test("returns focused guide in closed stage even when not the resolved guide", () => { + const stateWithGuides = { + guideGroups: [throttleDefaultGroup], + guideGroupDisplayLogs: {}, + guides: { onboarding: mockGuide }, + ineligibleGuides: {}, + previewGuides: {}, + queries: {}, + location: undefined, + counter: 0, + debug: { + focusedGuideKeys: { onboarding: true as const }, + }, + }; + + const client = new KnockGuideClient(mockKnock, channelId); + + // Set up a closed stage where a DIFFERENT guide was resolved + client["stage"] = { + status: "closed", + ordered: ["onboarding"], + resolved: "some_other_guide", + results: {}, + timeoutId: null, + }; + + // Focused guides bypass the resolved check in closed stage + const result = client.selectGuide(stateWithGuides, { + key: "onboarding", + }); + expect(result).toBeDefined(); + expect(result!.key).toBe("onboarding"); + }); }); describe("setDebug", () => { diff --git a/packages/react/src/modules/guide/components/Toolbar/V2/GuideContextDetails.tsx b/packages/react/src/modules/guide/components/Toolbar/V2/GuideContextDetails.tsx index 21a0f728f..8c916b8c9 100644 --- a/packages/react/src/modules/guide/components/Toolbar/V2/GuideContextDetails.tsx +++ b/packages/react/src/modules/guide/components/Toolbar/V2/GuideContextDetails.tsx @@ -14,10 +14,7 @@ export const GuideContextDetails = () => { const { defaultGroup, debugSettings } = useStore(client.store, (state) => { return { defaultGroup: state.guideGroups[0], - debugSettings: { - skipEngagementTracking: !!state.debug?.skipEngagementTracking, - ignoreDisplayInterval: !!state.debug?.ignoreDisplayInterval, - }, + debugSettings: state.debug || {}, }; }); const displayInterval = defaultGroup?.display_interval ?? null; diff --git a/packages/react/src/modules/guide/components/Toolbar/V2/GuideHoverCard.tsx b/packages/react/src/modules/guide/components/Toolbar/V2/GuideHoverCard.tsx index e305dec54..873e57488 100644 --- a/packages/react/src/modules/guide/components/Toolbar/V2/GuideHoverCard.tsx +++ b/packages/react/src/modules/guide/components/Toolbar/V2/GuideHoverCard.tsx @@ -1,5 +1,7 @@ import * as HoverCard from "@radix-ui/react-hover-card"; +import { Button } from "@telegraph/button"; import { Box, Stack } from "@telegraph/layout"; +import { ExternalLink, RotateCcw } from "lucide-react"; import * as React from "react"; import { @@ -47,6 +49,28 @@ export const GuideHoverCard = ({ maxHeight: "600px", }} > + + + +
 {
+  const { client } = useGuideContext();
+  const { debugSettings } = useStore(client.store, (state) => ({
+    debugSettings: state.debug || {},
+  }));
+
+  const focusedGuideKeys = debugSettings.focusedGuideKeys || {};
+  const hasFocus = Object.keys(focusedGuideKeys).length > 0;
+  const isFocused = !!focusedGuideKeys[guide.key];
+
   return (
     
       
@@ -43,7 +54,11 @@ export const GuideRow = ({ guide, orderIndex }: Props) => {
           >
             {orderIndex + 1}
           
-          
+          
             {guide.key}
           
         
@@ -52,6 +67,34 @@ export const GuideRow = ({ guide, orderIndex }: Props) => {
       
         {!isUnknownGuide(guide) && (
           <>
+            {guide.annotation.selectable.status !== undefined &&
+              (!hasFocus || isFocused) && (
+                <>
+                  
+