@agent-native/core-corpus 0.204.1-nightly-20261008153353 → 0.205.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/corpus/README.md +1 -1
- package/corpus/templates/analytics/.agents/skills/onboarding-journeys/SKILL.md +79 -0
- package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +6 -4
- package/corpus/templates/analytics/CHANGELOG.md +30 -0
- package/corpus/templates/analytics/actions/get-onboarding-journey.ts +211 -0
- package/corpus/templates/analytics/actions/upload-journey-frame.ts +88 -0
- package/corpus/templates/analytics/app/i18n/en-US.ts +4 -0
- package/corpus/templates/analytics/app/pages/sessions/SessionReplayFrame.tsx +192 -0
- package/corpus/templates/analytics/app/pages/sessions/SessionReplayStoryboardExportDialog.tsx +86 -40
- package/corpus/templates/analytics/app/pages/sessions/session-replay-frame.ts +92 -0
- package/corpus/templates/analytics/app/pages/sessions/session-replay-screenshot.ts +0 -207
- package/corpus/templates/analytics/app/root.tsx +8 -1
- package/corpus/templates/analytics/app/routes/sessions.$recordingId.tsx +9 -0
- package/corpus/templates/analytics/changelog/2026-10-07-session-storyboard-screenshots-use-private-upload-storage.md +6 -0
- package/corpus/templates/analytics/changelog/2026-10-08-agents-can-read-the-onboarding-journey-tree-and-render-its-frames.md +6 -0
- package/corpus/templates/analytics/changelog/2026-10-08-analytics-now-reports-uncertain-storyboard-saves-without-imp.md +6 -0
- package/corpus/templates/analytics/changelog/2026-10-08-replay-screenshot-exports-now-report-upload-timeouts-and-inc.md +6 -0
- package/corpus/templates/analytics/changelog/2026-10-08-session-replay-screenshots-capture-directly-without-requesti.md +6 -0
- package/corpus/templates/analytics/changelog/2026-10-08-storyboard-exports-now-ask-you-to-check-design-when-analytic.md +6 -0
- package/corpus/templates/analytics/package.json +1 -0
- package/corpus/templates/analytics/scripts/journey-capture-plan.ts +380 -0
- package/corpus/templates/analytics/scripts/journey-capture.ts +712 -0
- package/corpus/templates/analytics/server/lib/analytics-mcp.ts +1 -1
- package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +117 -0
- package/corpus/templates/analytics/server/lib/journey-steps.ts +182 -0
- package/corpus/templates/analytics/server/lib/journey-tree.ts +384 -0
- package/corpus/templates/analytics/server/lib/onboarding-journey.ts +385 -0
- package/corpus/templates/analytics/server/lib/session-friction.ts +213 -51
- package/corpus/templates/analytics/server/lib/session-replay.ts +261 -8
- package/corpus/templates/analytics/server/routes/api/session-replay/storyboard.post.ts +651 -187
- package/corpus/templates/analytics/shared/journey-path.ts +24 -0
- package/corpus/templates/analytics/shared/png.ts +38 -0
- package/corpus/templates/clips/CHANGELOG.md +23 -0
- package/corpus/templates/clips/actions/burn-recording-redactions.ts +1 -0
- package/corpus/templates/clips/actions/cleanup-transcript.ts +5 -1
- package/corpus/templates/clips/app/components/editor/editor-layout.tsx +240 -49
- package/corpus/templates/clips/app/components/editor/recording-editor-boundary.tsx +11 -0
- package/corpus/templates/clips/app/i18n/en-US.ts +5 -0
- package/corpus/templates/clips/app/lib/editor-save-status.ts +205 -0
- package/corpus/templates/clips/app/routes/_app.r.$recordingId.tsx +8 -19
- package/corpus/templates/clips/changelog/2026-10-08-skip-first-run-setup.md +6 -0
- package/corpus/templates/clips/changelog/2026-10-08-the-video-editor-confirms-when-edits-save-to-a-clip-and-prov.md +6 -0
- package/corpus/templates/clips/server/plugins/onboarding.ts +1 -1
- package/corpus/templates/content/CHANGELOG.md +21 -0
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +154 -53
- package/corpus/templates/content/app/components/editor/DocumentEditorSkeleton.tsx +7 -1
- package/corpus/templates/content/app/components/editor/DocumentToolbar.tsx +422 -168
- package/corpus/templates/content/app/components/editor/database/ContentTable.tsx +130 -3
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +68 -63
- package/corpus/templates/content/app/components/editor/document-editor-layout.ts +10 -3
- package/corpus/templates/content/app/components/layout/ContentStartupShell.tsx +5 -4
- package/corpus/templates/content/app/components/layout/Layout.tsx +125 -103
- package/corpus/templates/content/app/components/layout/content-layout.ts +228 -0
- package/corpus/templates/content/app/components/layout/sidebar-preferences.ts +1 -12
- package/corpus/templates/content/app/components/layout/use-content-layout.ts +290 -0
- package/corpus/templates/content/app/hooks/use-element-width-value.ts +49 -0
- package/corpus/templates/content/app/root.tsx +1 -1
- package/corpus/templates/content/changelog/2026-10-02-the-sidebar-comments-and-agent-panel-share-the-window-the-si.md +6 -0
- package/corpus/templates/content/changelog/2026-10-05-on-phones-a-collection-s-sort-and-filter-chips-stay-on-one-l.md +6 -0
- package/corpus/templates/content/changelog/2026-10-05-sharing-and-focus-remain-available-when-page-toolbar-folds.md +6 -0
- package/corpus/templates/design/.agents/skills/journey-storyboards/SKILL.md +48 -0
- package/corpus/templates/design/AGENTS.md +2 -0
- package/corpus/templates/design/CHANGELOG.md +30 -0
- package/corpus/templates/design/actions/add-session-replay-screenshots-to-board.ts +239 -69
- package/corpus/templates/design/actions/create-journey-canvas.ts +598 -0
- package/corpus/templates/design/actions/update-screen-source.ts +3 -1
- package/corpus/templates/design/app/components/design/DesignImportPanel.tsx +39 -2
- package/corpus/templates/design/app/components/design/LayersPanel.tsx +9 -12
- package/corpus/templates/design/app/components/visual-editor/ReviewCanvasPins.tsx +50 -10
- package/corpus/templates/design/app/i18n/en-US.ts +13 -1
- package/corpus/templates/design/app/pages/Index.tsx +2 -2
- package/corpus/templates/design/app/pages/design-editor/commands/start-sidebar-resize.ts +9 -12
- package/corpus/templates/design/app/pages/design-editor/design-editor-view.tsx +12 -7
- package/corpus/templates/design/app/pages/design-editor/domains/use-editor-history.ts +4 -1
- package/corpus/templates/design/app/pages/design-editor/domains/use-editor-source-and-sync.ts +18 -0
- package/corpus/templates/design/app/pages/design-editor/left-sidebar-width.ts +51 -0
- package/corpus/templates/design/app/pages/design-editor/view/export-submenu-content.tsx +93 -0
- package/corpus/templates/design/app/pages/design-editor/view/left-sidebar.tsx +15 -1
- package/corpus/templates/design/app/pages/design-editor/view/project-menu.tsx +11 -79
- package/corpus/templates/design/app/pages/design-editor/view/project-title-control.tsx +253 -43
- package/corpus/templates/design/changelog/2026-10-07-design-storyboard-screenshots-support-encrypted-upload.md +6 -0
- package/corpus/templates/design/changelog/2026-10-07-layers-panel-rows-are-now-32px-tall-with-a-24px-indent-per-l.md +6 -0
- package/corpus/templates/design/changelog/2026-10-08-agents-can-draw-an-onboarding-journey-storyboard-on-a-design.md +6 -0
- package/corpus/templates/design/changelog/2026-10-08-click-a-design-s-name-in-the-editor-to-open-a-file-menu-with.md +6 -0
- package/corpus/templates/design/changelog/2026-10-08-design-now-reports-pending-screenshot-cleanup-and-uncertain-.md +6 -0
- package/corpus/templates/design/changelog/2026-10-08-failed-screenshot-exports-report-when-temporary-image-cleanu.md +6 -0
- package/corpus/templates/design/e2e/chrome-geometry.reference.ts +5 -4
- package/corpus/templates/design/server/lib/replay-screenshot-blobs.ts +169 -0
- package/corpus/templates/design/server/lib/visual-edit-snapshot-blobs.ts +48 -6
- package/corpus/templates/design/server/routes/api/design-board-replay-screenshots/[screenshotId].get.ts +11 -2
- package/corpus/templates/design/server/routes/api/session-replay-storyboard.post.ts +669 -0
- package/corpus/templates/design/shared/journey-canvas.ts +841 -0
- package/corpus/templates/design/shared/journey-layout.ts +366 -0
- package/corpus/templates/factory/server/plugins/factory-scheduler-job.ts +27 -2
- package/corpus/templates/mail/server/lib/automation-model.ts +18 -3
- package/corpus/templates/slides/CHANGELOG.md +24 -0
- package/corpus/templates/slides/actions/generate-slides-ai.ts +5 -1
- package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +1 -0
- package/corpus/templates/slides/app/components/ui/context-menu.tsx +31 -0
- package/corpus/templates/slides/changelog/2026-10-08-image-uploads-work-reliably-in-slides.md +6 -0
- package/corpus/templates/slides/changelog/2026-10-08-right-click-menus-on-slides-open-instantly-without-the-brows.md +6 -0
- package/corpus/templates/slides/server/handlers/assets.ts +86 -4
- package/package.json +1 -1
package/corpus/README.md
CHANGED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: onboarding-journeys
|
|
3
|
+
description: >-
|
|
4
|
+
Build the onboarding journey tree (signup to first output, with % splits,
|
|
5
|
+
drop-off per step, and example replays) and render a screenshot per step. Use
|
|
6
|
+
when asked for an onboarding storyboard, where new users drop off, or frames
|
|
7
|
+
for each onboarding step.
|
|
8
|
+
scope: dev
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Onboarding Journeys
|
|
12
|
+
|
|
13
|
+
The pipeline runs without a UI. Codex does all four steps from the terminal:
|
|
14
|
+
|
|
15
|
+
1. Call `get-onboarding-journey` as an MCP tool of the deployed Analytics app.
|
|
16
|
+
Save the JSON result as `tree.json`.
|
|
17
|
+
2. Run the capture CLI from the agent-native checkout:
|
|
18
|
+
`pnpm --filter analytics journey:capture --tree tree.json --out ./frames`
|
|
19
|
+
(`--per-node 2 --concurrency 3`, `--min-aspect` / `--max-aspect`, `--dry-run`
|
|
20
|
+
to print the plan, `--upload` for private `attachmentRef`s).
|
|
21
|
+
3. Call Design's `create-journey-canvas` with
|
|
22
|
+
`{ title, tree, frames: [{ nodeKey, exampleIndex, imageUrl?, attachmentRef?, width, height, capturedAt }], designId? }`
|
|
23
|
+
-> `{ designId, url, nodeCount, frameCount }`. Build `frames` from
|
|
24
|
+
`frames/manifest.json`.
|
|
25
|
+
4. Lay out and review the canvas.
|
|
26
|
+
|
|
27
|
+
## The tree
|
|
28
|
+
|
|
29
|
+
`get-onboarding-journey` returns `JourneyTree`; `format: "summary"` returns the
|
|
30
|
+
same counts as an indented `outline` with no examples. Use it first to choose a
|
|
31
|
+
window, `app`, `maxDepth`, `minNodeSessions` (small branches merge into an
|
|
32
|
+
`other` node), and `maxNodes`.
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
type JourneyExample = { sessionId: string; recordingId: string | null; ts: string; offsetMs: number | null; viewport: { width: number; height: number } | null; viewportReason?: string; replayUrl?: string };
|
|
36
|
+
type JourneyNode = { key: string; label: string; parentKey: string | null; depth: number; kind: "step" | "other"; n: number; pctOfRoot: number; pctOfParent: number; dropoffN: number; dropoffPct: number; examples: JourneyExample[] };
|
|
37
|
+
type JourneyTree = { window: { from: string; to: string }; app: string; rootN: number; coverage: { sessionsWithEvents: number; sessionsWithReplay: number; truncated: boolean }; nodes: JourneyNode[]; notes?: string[] };
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
- Nodes come parents first. `key` is the path of step keys joined with ` > `;
|
|
41
|
+
`pctOf*` are percents (0-100).
|
|
42
|
+
- `dropoffN` is sessions whose last observed step is that node. It is not a
|
|
43
|
+
confirmed exit: a blocked tracker or an event outside the window reads the
|
|
44
|
+
same. A node at `maxDepth` where `n - dropoffN - sum(children n)` is above
|
|
45
|
+
zero has sessions that carried on.
|
|
46
|
+
- A session is an analytics session id. One that began before `dateFrom`
|
|
47
|
+
starts mid-journey, so start the window a day early.
|
|
48
|
+
- Read `coverage` before using the numbers. `truncated: true` means the event
|
|
49
|
+
read hit `maxEventRows` or the node list hit `maxNodes`; `notes` says which.
|
|
50
|
+
Never report a truncated tree as the whole window.
|
|
51
|
+
- An example with `recordingId: null` has no replay the caller can open, and
|
|
52
|
+
`viewportReason` says why the viewport is unknown (`no_recording`,
|
|
53
|
+
`not_captured` for recordings before viewport capture, `unreadable`).
|
|
54
|
+
- Test identities are always excluded; `emailFilter` defaults to
|
|
55
|
+
`exclude_builder`, as in the onboarding metrics. A session with any excluded
|
|
56
|
+
identity is dropped whole.
|
|
57
|
+
|
|
58
|
+
## Capture
|
|
59
|
+
|
|
60
|
+
The CLI mints a two-hour tokenized replay link per recording with
|
|
61
|
+
`create-session-replay-agent-link`, opens each recording once in headless
|
|
62
|
+
Chromium at `/sessions/:id?frame=1&agent_access=...`, seeks to each `offsetMs`,
|
|
63
|
+
and uses the Sessions screenshot compositor. It writes `<nodeKey>-<n>.png` and
|
|
64
|
+
`manifest.json` with `frames`, `failures` (explicit, with a reason), and
|
|
65
|
+
`skipped`. Fix failures or report them; do not paint over a missing frame.
|
|
66
|
+
|
|
67
|
+
Authenticate to the deployed app, never a local database: run
|
|
68
|
+
`npx -y @agent-native/core@latest connect https://analytics.agent-native.com --client codex`
|
|
69
|
+
(the CLI reads the bearer it writes to `~/.codex/config.toml`), or pass
|
|
70
|
+
`--token` / set `AGENT_NATIVE_TOKEN`. `--app-url` overrides the app. Frame mode
|
|
71
|
+
needs a deployment that includes `/sessions/:id?frame=1`.
|
|
72
|
+
|
|
73
|
+
Frame mode renders without a login; the signed link is the only credential. It
|
|
74
|
+
masks and scrubs exactly as the Sessions viewer does. Do not add viewport
|
|
75
|
+
clamping or pointer projection to it (see `session-replay`).
|
|
76
|
+
|
|
77
|
+
Viewport comes from the recording's first rrweb Meta event, stored by replay
|
|
78
|
+
ingest in `session_recordings.metadata.viewport` (`first` and `last`). Older
|
|
79
|
+
recordings have none.
|
|
@@ -154,10 +154,12 @@ agent answers about browser recordings in the Analytics template.
|
|
|
154
154
|
clients send no id and are skipped: followed together, two tabs would look
|
|
155
155
|
like one tab going back.
|
|
156
156
|
- A replay row counts only while `processed_chunks` equals the recording's
|
|
157
|
-
`chunk_count`. Each batch
|
|
158
|
-
|
|
159
|
-
cannot be measured, or
|
|
160
|
-
recording reads as unmeasured
|
|
157
|
+
`chunk_count`. Each batch continues from the stored detector state, whose
|
|
158
|
+
`lastSeq` is the highest chunk measured; a seq the recorder lost is skipped.
|
|
159
|
+
A batch that cannot be measured, or a chunk below `lastSeq`, leaves the row
|
|
160
|
+
behind and the recording reads as unmeasured until it ends. Then
|
|
161
|
+
`remeasureReplayFriction` measures every stored chunk again in seq order;
|
|
162
|
+
never continue a row that fell behind from fresh state. An event row
|
|
161
163
|
counts only when the tenant's friction coverage began before every
|
|
162
164
|
recording of the session and the session has neither an event index gap
|
|
163
165
|
nor a friction gap (`analytics_session_friction_gaps`). A friction write
|
|
@@ -3,14 +3,44 @@
|
|
|
3
3
|
All notable user-facing changes to Agent-Native Analytics are documented here. Open it any
|
|
4
4
|
time from the command menu (Cmd+K → "What's new") or from Settings.
|
|
5
5
|
|
|
6
|
+
## 2026-10-08
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- Agents can build the onboarding journey tree with drop-off per step and render a screenshot of each step from session replays.
|
|
11
|
+
|
|
12
|
+
### Improved
|
|
13
|
+
|
|
14
|
+
- Session replay screenshots capture directly without requesting screen sharing.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Analytics now reports uncertain storyboard saves without implying the export definitely failed.
|
|
19
|
+
- Replay screenshot exports now report upload timeouts and incomplete cleanup.
|
|
20
|
+
- Storyboard exports now ask you to check Design when Analytics cannot confirm a save.
|
|
21
|
+
|
|
6
22
|
## 2026-10-07
|
|
7
23
|
|
|
8
24
|
### Added
|
|
9
25
|
|
|
26
|
+
- Selected session replays can be captured at exact timestamps and sent together to a private Design storyboard.
|
|
10
27
|
- Session replay screenshots can be copied directly into Design.
|
|
11
28
|
|
|
29
|
+
### Improved
|
|
30
|
+
|
|
31
|
+
- Analytics chat follows short follow-ups like "what about EMEA?" and recovers from an unknown BigQuery column in one step
|
|
32
|
+
- Thumbs-down on a chat answer now offers quick reasons and a Copy details button that copies the run id and thread link for a report
|
|
33
|
+
|
|
12
34
|
### Fixed
|
|
13
35
|
|
|
36
|
+
- Analytics now treats chart filter changes as data requests and reports when reference catalogs are incomplete.
|
|
37
|
+
- Analytics chat no longer stays on "running" after the agent has finished; the thread reloads so the change is visible without asking again
|
|
38
|
+
- Analytics chat stops retrying a data source that is not connected and says who can connect it
|
|
39
|
+
- Analytics keeps S3 replay setup available when storage status cannot be checked.
|
|
40
|
+
- Automatic learning captures stay private to the user, and shared learning updates require approval.
|
|
41
|
+
- Builder storage setup now waits for renewed file access before confirming it is ready.
|
|
42
|
+
- Personal Analytics memories stay limited to unscoped conversations, keeping organization-specific guidance from following users between organizations.
|
|
43
|
+
- Session replay storyboard screenshots upload directly to Design's private storage, so Analytics does not need its own blob storage provider.
|
|
14
44
|
- Session replay screenshot export captures readable video frames and safe poster images.
|
|
15
45
|
|
|
16
46
|
## 2026-10-06
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { defineAction, fail } from "@agent-native/core/action";
|
|
2
|
+
import {
|
|
3
|
+
getRequestOrgId,
|
|
4
|
+
getRequestUserEmail,
|
|
5
|
+
} from "@agent-native/core/server";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
FIRST_PARTY_TEMPLATE_NAMES,
|
|
10
|
+
isCalendarDate,
|
|
11
|
+
} from "../server/lib/first-party-metric-catalog.js";
|
|
12
|
+
import {
|
|
13
|
+
getOnboardingJourney,
|
|
14
|
+
JourneyRecordingsError,
|
|
15
|
+
} from "../server/lib/onboarding-journey.js";
|
|
16
|
+
|
|
17
|
+
const MAX_WINDOW_DAYS = 90;
|
|
18
|
+
|
|
19
|
+
function resolveScope() {
|
|
20
|
+
const userEmail = getRequestUserEmail();
|
|
21
|
+
if (!userEmail)
|
|
22
|
+
fail("Sign in to use this action.", {
|
|
23
|
+
errorCode: "unauthenticated",
|
|
24
|
+
statusCode: 401,
|
|
25
|
+
});
|
|
26
|
+
return { userEmail, orgId: getRequestOrgId() || null };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const isoDate = z
|
|
30
|
+
.string()
|
|
31
|
+
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
|
32
|
+
.refine(isCalendarDate, { message: "must be a real calendar date" });
|
|
33
|
+
|
|
34
|
+
export default defineAction({
|
|
35
|
+
description:
|
|
36
|
+
'Build the onboarding journey tree for a date window: per-session step sequences (signup, onboarding questions, setup method, app entry, first action, first output) folded into a prefix tree. format "tree" returns { window, app, rootN, coverage, nodes }: each node has n, pctOfRoot, pctOfParent, dropoffN/dropoffPct (sessions whose last observed step is that node, not a confirmed exit) and example sessions with recordingId, offsetMs to seek to, and the recording\'s viewport. format "summary" returns the same counts as an indented text outline with no examples. Check coverage.truncated first: true means counts are a partial sample or the node list was cut (see notes). Use it to plan onboarding storyboards or find where new users stop; feed the tree to the journey:capture CLI to render frames.',
|
|
37
|
+
schema: z.object({
|
|
38
|
+
dateFrom: isoDate.describe(
|
|
39
|
+
"Inclusive UTC start date, YYYY-MM-DD. Sessions that began earlier appear mid-journey, so start a day before the period you care about.",
|
|
40
|
+
),
|
|
41
|
+
dateTo: isoDate.describe(
|
|
42
|
+
`Inclusive UTC end date, YYYY-MM-DD, at most ${MAX_WINDOW_DAYS} days after dateFrom. Events are windowed before any join.`,
|
|
43
|
+
),
|
|
44
|
+
app: z
|
|
45
|
+
.enum(["all", ...FIRST_PARTY_TEMPLATE_NAMES])
|
|
46
|
+
.optional()
|
|
47
|
+
.default("all")
|
|
48
|
+
.describe(
|
|
49
|
+
'Template filter: "all" or one first-party template such as clips, design, or slides. Defaults to "all".',
|
|
50
|
+
),
|
|
51
|
+
emailFilter: z
|
|
52
|
+
.enum(["all", "exclude_builder", "only_builder"])
|
|
53
|
+
.optional()
|
|
54
|
+
.default("exclude_builder")
|
|
55
|
+
.describe(
|
|
56
|
+
'Builder-employee scope, as in the onboarding metrics: "exclude_builder" (default), "only_builder", or "all". Test identities are always excluded.',
|
|
57
|
+
),
|
|
58
|
+
format: z
|
|
59
|
+
.enum(["tree", "summary"])
|
|
60
|
+
.optional()
|
|
61
|
+
.default("tree")
|
|
62
|
+
.describe(
|
|
63
|
+
'Output shape: "tree" (default) with example sessions, or "summary", an indented text outline of counts and drop-off with no examples.',
|
|
64
|
+
),
|
|
65
|
+
maxNodes: z.coerce
|
|
66
|
+
.number()
|
|
67
|
+
.int()
|
|
68
|
+
.min(1)
|
|
69
|
+
.max(500)
|
|
70
|
+
.optional()
|
|
71
|
+
.default(60)
|
|
72
|
+
.describe(
|
|
73
|
+
"Most nodes returned, largest first. When the tree has more, coverage.truncated is true and notes says how many were cut. Defaults to 60.",
|
|
74
|
+
),
|
|
75
|
+
maxDepth: z.coerce
|
|
76
|
+
.number()
|
|
77
|
+
.int()
|
|
78
|
+
.min(1)
|
|
79
|
+
.max(20)
|
|
80
|
+
.optional()
|
|
81
|
+
.default(8)
|
|
82
|
+
.describe(
|
|
83
|
+
"Steps kept per session before sessions are folded into the deeperN count of the node at this depth. Defaults to 8.",
|
|
84
|
+
),
|
|
85
|
+
minNodeSessions: z.coerce
|
|
86
|
+
.number()
|
|
87
|
+
.int()
|
|
88
|
+
.min(1)
|
|
89
|
+
.optional()
|
|
90
|
+
.default(1)
|
|
91
|
+
.describe(
|
|
92
|
+
'Branches with fewer sessions than this merge into one "other" child per parent. Defaults to 1 (nothing merges).',
|
|
93
|
+
),
|
|
94
|
+
examplesPerNode: z.coerce
|
|
95
|
+
.number()
|
|
96
|
+
.int()
|
|
97
|
+
.min(1)
|
|
98
|
+
.max(10)
|
|
99
|
+
.optional()
|
|
100
|
+
.default(3)
|
|
101
|
+
.describe("Example sessions returned per node. Defaults to 3."),
|
|
102
|
+
maxEventRows: z.coerce
|
|
103
|
+
.number()
|
|
104
|
+
.int()
|
|
105
|
+
.min(1000)
|
|
106
|
+
.max(200000)
|
|
107
|
+
.optional()
|
|
108
|
+
.default(40000)
|
|
109
|
+
.describe(
|
|
110
|
+
"Event rows to read before stopping and reporting coverage.truncated. Each 4,000 rows is one query; raise it only for a wide window. Defaults to 40000.",
|
|
111
|
+
),
|
|
112
|
+
recency: z
|
|
113
|
+
.enum(["newest", "none"])
|
|
114
|
+
.optional()
|
|
115
|
+
.default("newest")
|
|
116
|
+
.describe(
|
|
117
|
+
'Example order after replay availability: "newest" (default) prefers recent sessions, "none" orders by session id.',
|
|
118
|
+
),
|
|
119
|
+
settleMs: z.coerce
|
|
120
|
+
.number()
|
|
121
|
+
.int()
|
|
122
|
+
.min(0)
|
|
123
|
+
.max(5000)
|
|
124
|
+
.optional()
|
|
125
|
+
.default(300)
|
|
126
|
+
.describe(
|
|
127
|
+
"Added to each example's offsetMs so the frame has rendered. Defaults to 300.",
|
|
128
|
+
),
|
|
129
|
+
minAspect: z.coerce
|
|
130
|
+
.number()
|
|
131
|
+
.positive()
|
|
132
|
+
.optional()
|
|
133
|
+
.describe("Only examples whose viewport width/height is at least this."),
|
|
134
|
+
maxAspect: z.coerce
|
|
135
|
+
.number()
|
|
136
|
+
.positive()
|
|
137
|
+
.optional()
|
|
138
|
+
.describe("Only examples whose viewport width/height is at most this."),
|
|
139
|
+
minWidth: z.coerce
|
|
140
|
+
.number()
|
|
141
|
+
.int()
|
|
142
|
+
.positive()
|
|
143
|
+
.optional()
|
|
144
|
+
.describe("Only examples whose viewport is at least this many px wide."),
|
|
145
|
+
maxWidth: z.coerce
|
|
146
|
+
.number()
|
|
147
|
+
.int()
|
|
148
|
+
.positive()
|
|
149
|
+
.optional()
|
|
150
|
+
.describe("Only examples whose viewport is at most this many px wide."),
|
|
151
|
+
requireKnownViewport: z
|
|
152
|
+
.boolean()
|
|
153
|
+
.optional()
|
|
154
|
+
.describe(
|
|
155
|
+
"Leave out examples whose viewport was not recorded or cannot be read. Without it they are kept with viewport null and a viewportReason.",
|
|
156
|
+
),
|
|
157
|
+
}),
|
|
158
|
+
http: { method: "GET" },
|
|
159
|
+
readOnly: true,
|
|
160
|
+
mcpTool: true,
|
|
161
|
+
publicAgent: { expose: true, readOnly: true, requiresAuth: true },
|
|
162
|
+
grounding: true,
|
|
163
|
+
run: async (args) => {
|
|
164
|
+
const days =
|
|
165
|
+
(Date.parse(`${args.dateTo}T00:00:00Z`) -
|
|
166
|
+
Date.parse(`${args.dateFrom}T00:00:00Z`)) /
|
|
167
|
+
86_400_000;
|
|
168
|
+
if (days < 0) fail("dateTo must not be before dateFrom.");
|
|
169
|
+
if (days > MAX_WINDOW_DAYS) {
|
|
170
|
+
fail(`The window may span at most ${MAX_WINDOW_DAYS} days.`);
|
|
171
|
+
}
|
|
172
|
+
const viewport = {
|
|
173
|
+
...(args.minAspect !== undefined ? { minAspect: args.minAspect } : {}),
|
|
174
|
+
...(args.maxAspect !== undefined ? { maxAspect: args.maxAspect } : {}),
|
|
175
|
+
...(args.minWidth !== undefined ? { minWidth: args.minWidth } : {}),
|
|
176
|
+
...(args.maxWidth !== undefined ? { maxWidth: args.maxWidth } : {}),
|
|
177
|
+
...(args.requireKnownViewport ? { requireKnown: true } : {}),
|
|
178
|
+
};
|
|
179
|
+
const scope = resolveScope();
|
|
180
|
+
try {
|
|
181
|
+
return await getOnboardingJourney(scope, {
|
|
182
|
+
dateFrom: args.dateFrom,
|
|
183
|
+
dateTo: args.dateTo,
|
|
184
|
+
app: args.app,
|
|
185
|
+
emailFilter: args.emailFilter,
|
|
186
|
+
format: args.format,
|
|
187
|
+
maxNodes: args.maxNodes,
|
|
188
|
+
maxDepth: args.maxDepth,
|
|
189
|
+
minNodeSessions: args.minNodeSessions,
|
|
190
|
+
examplesPerNode: args.examplesPerNode,
|
|
191
|
+
maxEventRows: args.maxEventRows,
|
|
192
|
+
settleMs: args.settleMs,
|
|
193
|
+
recency: args.recency,
|
|
194
|
+
viewport: Object.keys(viewport).length ? viewport : undefined,
|
|
195
|
+
});
|
|
196
|
+
} catch (error) {
|
|
197
|
+
// Causes can quote database details; the server log keeps them.
|
|
198
|
+
console.error("[get-onboarding-journey] failed", error);
|
|
199
|
+
if (error instanceof JourneyRecordingsError) {
|
|
200
|
+
fail(
|
|
201
|
+
`${error.message} Examples would misreport which sessions have a replay, so no tree was built; retry, or use format "summary" for counts only.`,
|
|
202
|
+
{ errorCode: "journey_recordings_unreadable", statusCode: 502 },
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
fail(
|
|
206
|
+
"Onboarding journey events could not be read, so no tree was built. Retry with a narrower window or app.",
|
|
207
|
+
{ errorCode: "journey_events_unreadable", statusCode: 502 },
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { defineAction, fail } from "@agent-native/core/action";
|
|
2
|
+
import { mintAttachmentRef } from "@agent-native/core/private-blob";
|
|
3
|
+
import {
|
|
4
|
+
getRequestOrgId,
|
|
5
|
+
getRequestUserEmail,
|
|
6
|
+
} from "@agent-native/core/server";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
|
|
9
|
+
import { getSessionReplaySummary } from "../server/lib/session-replay.js";
|
|
10
|
+
import { pngDimensions } from "../shared/png.js";
|
|
11
|
+
|
|
12
|
+
const MAX_PNG_BYTES = 3_500_000;
|
|
13
|
+
|
|
14
|
+
export default defineAction({
|
|
15
|
+
description:
|
|
16
|
+
"Store one onboarding journey frame (a PNG screenshot of a session replay) in the caller's private storage and return an opaque attachmentRef. Used by the journey:capture CLI with --upload; frames are never public and only the caller can open the ref. The caller must be able to open the recording.",
|
|
17
|
+
schema: z.object({
|
|
18
|
+
recordingId: z
|
|
19
|
+
.string()
|
|
20
|
+
.min(1)
|
|
21
|
+
.max(128)
|
|
22
|
+
.describe("The recording the frame shows"),
|
|
23
|
+
offsetMs: z.coerce
|
|
24
|
+
.number()
|
|
25
|
+
.int()
|
|
26
|
+
.min(0)
|
|
27
|
+
.describe("Where in the recording the frame was captured"),
|
|
28
|
+
png: z
|
|
29
|
+
.string()
|
|
30
|
+
.min(1)
|
|
31
|
+
.max(Math.ceil((MAX_PNG_BYTES * 4) / 3) + 8)
|
|
32
|
+
.describe(`Base64 PNG, at most ${MAX_PNG_BYTES} bytes decoded`),
|
|
33
|
+
}),
|
|
34
|
+
agentTool: false,
|
|
35
|
+
run: async (args) => {
|
|
36
|
+
const userEmail = getRequestUserEmail();
|
|
37
|
+
if (!userEmail) {
|
|
38
|
+
fail("Sign in to use this action.", {
|
|
39
|
+
errorCode: "unauthenticated",
|
|
40
|
+
statusCode: 401,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
const orgId = getRequestOrgId() || null;
|
|
44
|
+
const recording = await getSessionReplaySummary(args.recordingId, {
|
|
45
|
+
userEmail,
|
|
46
|
+
orgId,
|
|
47
|
+
});
|
|
48
|
+
const data = Buffer.from(args.png, "base64");
|
|
49
|
+
if (data.byteLength === 0 || data.byteLength > MAX_PNG_BYTES) {
|
|
50
|
+
fail(`The frame must be a PNG of at most ${MAX_PNG_BYTES} bytes.`, {
|
|
51
|
+
errorCode: "journey_frame_invalid",
|
|
52
|
+
statusCode: 413,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
const dimensions = pngDimensions(data);
|
|
56
|
+
if (!dimensions) {
|
|
57
|
+
fail("The frame is not a PNG of a plausible screenshot size.", {
|
|
58
|
+
errorCode: "journey_frame_invalid",
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
const minted = await mintAttachmentRef({
|
|
62
|
+
data,
|
|
63
|
+
filename: `${recording.id}-${String(args.offsetMs).padStart(8, "0")}.png`,
|
|
64
|
+
mimeType: "image/png",
|
|
65
|
+
ownerEmail: userEmail,
|
|
66
|
+
orgId: null,
|
|
67
|
+
metadata: {
|
|
68
|
+
appId: "analytics",
|
|
69
|
+
resourceType: "journey-frame",
|
|
70
|
+
resourceId: recording.id,
|
|
71
|
+
replayId: recording.id,
|
|
72
|
+
offsetMs: args.offsetMs,
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
if (minted.status !== "ok") {
|
|
76
|
+
fail(
|
|
77
|
+
"Private frame storage is not available for Analytics, so the frame was not stored.",
|
|
78
|
+
{ errorCode: "private_storage_unavailable", statusCode: 503 },
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
attachmentRef: minted.ref,
|
|
83
|
+
width: dimensions.width,
|
|
84
|
+
height: dimensions.height,
|
|
85
|
+
bytes: data.byteLength,
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
});
|
|
@@ -1698,8 +1698,12 @@ export default {
|
|
|
1698
1698
|
storyboardNoDesignResponse: "Design returned no storyboard result.",
|
|
1699
1699
|
storyboardTemporaryCleanupPending:
|
|
1700
1700
|
"The storyboard was saved, but temporary screenshot files could not be removed.",
|
|
1701
|
+
storyboardTemporaryCleanupFailed:
|
|
1702
|
+
"Temporary screenshot cleanup is still pending.",
|
|
1701
1703
|
storyboardUnexpectedResponse:
|
|
1702
1704
|
"The screenshot export returned an unreadable response. Retry the export.",
|
|
1705
|
+
storyboardSaveOutcomeUnknown:
|
|
1706
|
+
"Design may have saved the storyboard. Check Design before retrying.",
|
|
1703
1707
|
openStoryboard: "Open storyboard in Design",
|
|
1704
1708
|
cancelStoryboardCapture: "Cancel capture",
|
|
1705
1709
|
captureToDesign: "Capture and add to Design",
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { isScreenshotSize } from "@shared/png";
|
|
2
|
+
import { SESSION_REPLAY_AGENT_ACCESS_PARAM } from "@shared/session-replay-agent-access";
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
blobToBase64,
|
|
7
|
+
replayFrameFailureReason,
|
|
8
|
+
replayFramePath,
|
|
9
|
+
type ReplayFrameCapture,
|
|
10
|
+
} from "./session-replay-frame";
|
|
11
|
+
import { captureReplayScreenshot } from "./session-replay-screenshot";
|
|
12
|
+
import {
|
|
13
|
+
buildReplayViewportTimeline,
|
|
14
|
+
fetchSessionReplayPlayback,
|
|
15
|
+
normalizeReplayEvents,
|
|
16
|
+
REPLAY_OVERLAY_STYLE_RULES,
|
|
17
|
+
replayAvailabilityErrorKey,
|
|
18
|
+
replayInitialViewportDimensions,
|
|
19
|
+
replayRouteAtOffset,
|
|
20
|
+
replayViewportDimensionsAtTime,
|
|
21
|
+
} from "./SessionDetailPage";
|
|
22
|
+
|
|
23
|
+
function nextPaint(): Promise<void> {
|
|
24
|
+
return new Promise((resolve) =>
|
|
25
|
+
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export default function SessionReplayFrame({
|
|
30
|
+
recordingId,
|
|
31
|
+
}: {
|
|
32
|
+
recordingId: string;
|
|
33
|
+
}) {
|
|
34
|
+
const stageRef = useRef<HTMLDivElement>(null);
|
|
35
|
+
const stageRootRef = useRef<HTMLDivElement>(null);
|
|
36
|
+
const [status, setStatus] = useState<"loading" | "ready" | "error">(
|
|
37
|
+
"loading",
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
let cancelled = false;
|
|
42
|
+
let replayer: any = null;
|
|
43
|
+
window.__anReplayFrame = { status: "loading" };
|
|
44
|
+
|
|
45
|
+
const fail = (error: unknown) => {
|
|
46
|
+
if (cancelled) return;
|
|
47
|
+
window.__anReplayFrame = {
|
|
48
|
+
status: "error",
|
|
49
|
+
reason: replayFrameFailureReason(error),
|
|
50
|
+
};
|
|
51
|
+
setStatus("error");
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
async function boot() {
|
|
55
|
+
const stage = stageRef.current;
|
|
56
|
+
const stageRoot = stageRootRef.current;
|
|
57
|
+
if (!stage || !stageRoot) throw new Error("frame_not_mounted");
|
|
58
|
+
const agentAccessToken =
|
|
59
|
+
new URLSearchParams(window.location.search).get(
|
|
60
|
+
SESSION_REPLAY_AGENT_ACCESS_PARAM,
|
|
61
|
+
) ?? "";
|
|
62
|
+
const playback = await fetchSessionReplayPlayback(recordingId, {
|
|
63
|
+
agentAccessToken,
|
|
64
|
+
});
|
|
65
|
+
if (
|
|
66
|
+
!playback.isComplete ||
|
|
67
|
+
playback.unavailableChunks > 0 ||
|
|
68
|
+
playback.chunks.some((chunk) => chunk.unavailable)
|
|
69
|
+
) {
|
|
70
|
+
throw new Error("replay_incomplete");
|
|
71
|
+
}
|
|
72
|
+
const events = normalizeReplayEvents(
|
|
73
|
+
playback.chunks.flatMap((chunk) => chunk.events),
|
|
74
|
+
);
|
|
75
|
+
const unavailable = replayAvailabilityErrorKey(events);
|
|
76
|
+
if (unavailable) throw new Error(unavailable);
|
|
77
|
+
const initial = replayInitialViewportDimensions(events);
|
|
78
|
+
if (!initial) throw new Error("viewport_unavailable");
|
|
79
|
+
const timeline = buildReplayViewportTimeline(events);
|
|
80
|
+
// Sizes come from the recorded events, so one the browser could not
|
|
81
|
+
// render is refused before any surface is allocated for it.
|
|
82
|
+
if (
|
|
83
|
+
!timeline.every((change) =>
|
|
84
|
+
isScreenshotSize(change.width, change.height),
|
|
85
|
+
)
|
|
86
|
+
) {
|
|
87
|
+
throw new Error("viewport_out_of_range");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
await import("@rrweb/replay/dist/style.css");
|
|
91
|
+
const { Replayer } = await import("@rrweb/replay");
|
|
92
|
+
if (cancelled) return;
|
|
93
|
+
|
|
94
|
+
const sizeStage = (width: number, height: number) => {
|
|
95
|
+
stageRoot.style.width = `${width}px`;
|
|
96
|
+
stageRoot.style.height = `${height}px`;
|
|
97
|
+
stageRoot.style.transform = "none";
|
|
98
|
+
stageRoot.style.setProperty("--an-replay-cursor-scale", "1");
|
|
99
|
+
};
|
|
100
|
+
sizeStage(initial.width, initial.height);
|
|
101
|
+
replayer = new Replayer(events as any[], {
|
|
102
|
+
root: stageRoot,
|
|
103
|
+
speed: 1,
|
|
104
|
+
skipInactive: false,
|
|
105
|
+
showWarning: false,
|
|
106
|
+
showDebug: false,
|
|
107
|
+
mouseTail: false,
|
|
108
|
+
triggerFocus: true,
|
|
109
|
+
insertStyleRules: REPLAY_OVERLAY_STYLE_RULES,
|
|
110
|
+
});
|
|
111
|
+
replayer.iframe?.setAttribute?.("referrerpolicy", "no-referrer");
|
|
112
|
+
const totalTimeMs = Number(replayer.getMetaData?.().totalTime ?? 0);
|
|
113
|
+
try {
|
|
114
|
+
replayer.play?.(0);
|
|
115
|
+
} catch {
|
|
116
|
+
// rrweb throws when the first event cannot start playback; a pause at
|
|
117
|
+
// 0 still builds the first snapshot, which is all a seek needs.
|
|
118
|
+
replayer.pause?.(0);
|
|
119
|
+
}
|
|
120
|
+
await nextPaint();
|
|
121
|
+
|
|
122
|
+
let queue: Promise<unknown> = Promise.resolve();
|
|
123
|
+
const captureOne = async (
|
|
124
|
+
offsetMs: number,
|
|
125
|
+
): Promise<ReplayFrameCapture> => {
|
|
126
|
+
if (!Number.isFinite(offsetMs) || offsetMs < 0) {
|
|
127
|
+
throw new Error("offset_invalid");
|
|
128
|
+
}
|
|
129
|
+
if (offsetMs > totalTimeMs) throw new Error("offset_out_of_range");
|
|
130
|
+
const dimensions =
|
|
131
|
+
replayViewportDimensionsAtTime(timeline, offsetMs) ?? initial;
|
|
132
|
+
replayer.pause(offsetMs);
|
|
133
|
+
replayer.handleResize?.(dimensions);
|
|
134
|
+
sizeStage(dimensions.width, dimensions.height);
|
|
135
|
+
await nextPaint();
|
|
136
|
+
const iframe = replayer.iframe as HTMLIFrameElement | undefined;
|
|
137
|
+
if (!iframe) throw new Error("replay_frame_missing");
|
|
138
|
+
const blob = await captureReplayScreenshot(stage, stageRoot, iframe);
|
|
139
|
+
return {
|
|
140
|
+
offsetMs,
|
|
141
|
+
width: dimensions.width,
|
|
142
|
+
height: dimensions.height,
|
|
143
|
+
route: replayFramePath(replayRouteAtOffset(events, offsetMs)),
|
|
144
|
+
capturedAt: new Date().toISOString(),
|
|
145
|
+
png: await blobToBase64(blob),
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
window.__anReplayFrame = {
|
|
150
|
+
status: "ready",
|
|
151
|
+
recordingId,
|
|
152
|
+
totalTimeMs,
|
|
153
|
+
eventCount: playback.recording.eventCount,
|
|
154
|
+
// One seek at a time: rrweb has a single playhead.
|
|
155
|
+
capture: (offsetMs) => {
|
|
156
|
+
const run = queue.then(() => captureOne(offsetMs));
|
|
157
|
+
queue = run.catch(() => undefined);
|
|
158
|
+
return run;
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
setStatus("ready");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
boot().catch(fail);
|
|
165
|
+
return () => {
|
|
166
|
+
cancelled = true;
|
|
167
|
+
try {
|
|
168
|
+
replayer?.pause?.();
|
|
169
|
+
replayer?.destroy?.();
|
|
170
|
+
// coercion-ok: teardown of a player the page is leaving; nothing can act on a failure here.
|
|
171
|
+
} catch {
|
|
172
|
+
// Nothing to report: the page is going away.
|
|
173
|
+
}
|
|
174
|
+
window.__anReplayFrame = undefined;
|
|
175
|
+
};
|
|
176
|
+
}, [recordingId]);
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<div
|
|
180
|
+
ref={stageRef}
|
|
181
|
+
data-replay-frame={status}
|
|
182
|
+
// guard:allow-raw-color — a recording with no page background renders on the browser's default white, and the frame must match it in every theme
|
|
183
|
+
style={{ position: "fixed", left: 0, top: 0, background: "#fff" }}
|
|
184
|
+
>
|
|
185
|
+
<div
|
|
186
|
+
ref={stageRootRef}
|
|
187
|
+
className="an-replay-stage-root"
|
|
188
|
+
style={{ position: "relative", overflow: "hidden" }}
|
|
189
|
+
/>
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
}
|