@rose42/t3-feedback 0.1.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/LICENSE +21 -0
- package/README.md +516 -0
- package/dist/bridge/attachments.d.ts +27 -0
- package/dist/bridge/attachments.js +109 -0
- package/dist/bridge/format.d.ts +70 -0
- package/dist/bridge/format.js +358 -0
- package/dist/bridge/http.d.ts +31 -0
- package/dist/bridge/http.js +169 -0
- package/dist/bridge/index.d.ts +13 -0
- package/dist/bridge/index.js +12 -0
- package/dist/bridge/parse.d.ts +16 -0
- package/dist/bridge/parse.js +82 -0
- package/dist/bridge/paths.d.ts +10 -0
- package/dist/bridge/paths.js +10 -0
- package/dist/bridge/probeThreads.d.ts +13 -0
- package/dist/bridge/probeThreads.js +66 -0
- package/dist/bridge/readSource.d.ts +8 -0
- package/dist/bridge/readSource.js +64 -0
- package/dist/bridge/sendFeedback.d.ts +99 -0
- package/dist/bridge/sendFeedback.js +417 -0
- package/dist/cli/bin.d.ts +2 -0
- package/dist/cli/bin.js +40 -0
- package/dist/cli/constants.d.ts +4 -0
- package/dist/cli/constants.js +4 -0
- package/dist/cli/detect.d.ts +17 -0
- package/dist/cli/detect.js +118 -0
- package/dist/cli/index.d.ts +4 -0
- package/dist/cli/index.js +4 -0
- package/dist/cli/init.d.ts +18 -0
- package/dist/cli/init.js +165 -0
- package/dist/cli/pm.d.ts +12 -0
- package/dist/cli/pm.js +49 -0
- package/dist/cli/prompt.d.ts +2 -0
- package/dist/cli/prompt.js +19 -0
- package/dist/cli/wire/next.d.ts +8 -0
- package/dist/cli/wire/next.js +71 -0
- package/dist/cli/wire/node.d.ts +3 -0
- package/dist/cli/wire/node.js +76 -0
- package/dist/cli/wire/vite.d.ts +25 -0
- package/dist/cli/wire/vite.js +71 -0
- package/dist/client/activeThreads.d.ts +23 -0
- package/dist/client/activeThreads.js +89 -0
- package/dist/client/cropScreenshot.d.ts +39 -0
- package/dist/client/cropScreenshot.js +212 -0
- package/dist/client/customPlugins.d.ts +7 -0
- package/dist/client/customPlugins.js +38 -0
- package/dist/client/draft.d.ts +33 -0
- package/dist/client/draft.js +222 -0
- package/dist/client/index.d.ts +24 -0
- package/dist/client/index.js +20 -0
- package/dist/client/mount.d.ts +210 -0
- package/dist/client/mount.js +2408 -0
- package/dist/client/openInT3.d.ts +28 -0
- package/dist/client/openInT3.js +81 -0
- package/dist/client/readClipboardImage.d.ts +2 -0
- package/dist/client/readClipboardImage.js +2 -0
- package/dist/client/reportType.d.ts +43 -0
- package/dist/client/reportType.js +110 -0
- package/dist/client/selectBurstFrames.d.ts +21 -0
- package/dist/client/selectBurstFrames.js +101 -0
- package/dist/client/sessionThread.d.ts +26 -0
- package/dist/client/sessionThread.js +93 -0
- package/dist/client/threadPicker.d.ts +20 -0
- package/dist/client/threadPicker.js +51 -0
- package/dist/client/transport.d.ts +11 -0
- package/dist/client/transport.js +35 -0
- package/dist/client/ui/commentPanelPlugin.d.ts +46 -0
- package/dist/client/ui/commentPanelPlugin.js +126 -0
- package/dist/client/ui/defaultPlugins.d.ts +19 -0
- package/dist/client/ui/defaultPlugins.js +48 -0
- package/dist/client/ui/elementTargetChip.d.ts +64 -0
- package/dist/client/ui/elementTargetChip.js +185 -0
- package/dist/client/ui/elementTargetPanelPlugin.d.ts +43 -0
- package/dist/client/ui/elementTargetPanelPlugin.js +167 -0
- package/dist/client/ui/elementTargetPanelSession.d.ts +49 -0
- package/dist/client/ui/elementTargetPanelSession.js +279 -0
- package/dist/client/ui/elementTargetUi.d.ts +36 -0
- package/dist/client/ui/elementTargetUi.js +105 -0
- package/dist/client/ui/host.d.ts +112 -0
- package/dist/client/ui/host.js +132 -0
- package/dist/client/ui/html.d.ts +22 -0
- package/dist/client/ui/html.js +26 -0
- package/dist/client/ui/labeledPluginPreview.d.ts +8 -0
- package/dist/client/ui/labeledPluginPreview.js +71 -0
- package/dist/client/ui/resolveContributions.d.ts +13 -0
- package/dist/client/ui/resolveContributions.js +20 -0
- package/dist/client/ui/screenshotPanelPlugin.d.ts +39 -0
- package/dist/client/ui/screenshotPanelPlugin.js +212 -0
- package/dist/client/ui/screenshotPanelSession.d.ts +84 -0
- package/dist/client/ui/screenshotPanelSession.js +445 -0
- package/dist/client/ui/screenshotUi.d.ts +31 -0
- package/dist/client/ui/screenshotUi.js +130 -0
- package/dist/client/ui/shortcuts.d.ts +32 -0
- package/dist/client/ui/shortcuts.js +40 -0
- package/dist/client/ui/slots.d.ts +73 -0
- package/dist/client/ui/slots.js +112 -0
- package/dist/controls.d.ts +8 -0
- package/dist/controls.js +16 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/plugin.d.ts +9 -0
- package/dist/plugin.js +186 -0
- package/dist/types.d.ts +464 -0
- package/dist/types.js +283 -0
- package/docs/devcontainer.md +160 -0
- package/package.json +107 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { T3FeedbackOpenHandoff } from "../types.js";
|
|
2
|
+
/** Named window reused so pair → thread stays in one tab. */
|
|
3
|
+
export declare const T3_OPEN_WINDOW_NAME = "t3-code";
|
|
4
|
+
/**
|
|
5
|
+
* Delay between opening `/pair#token=…` and navigating to the thread.
|
|
6
|
+
* Long enough for local auto-pair; short enough to feel snappy when already paired.
|
|
7
|
+
*/
|
|
8
|
+
export declare const T3_PAIR_THEN_THREAD_DELAY_MS = 1800;
|
|
9
|
+
export type OpenInT3SeamlessOptions = {
|
|
10
|
+
threadId: string;
|
|
11
|
+
/** Bare thread URL used when minting fails or as progressive-enhancement href. */
|
|
12
|
+
fallbackUrl: string;
|
|
13
|
+
/** Vite middleware base, e.g. `/__t3-feedback`. */
|
|
14
|
+
endpoint: string;
|
|
15
|
+
fetchFn?: typeof fetch;
|
|
16
|
+
openWindow?: typeof window.open;
|
|
17
|
+
delayMs?: number;
|
|
18
|
+
schedule?: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>;
|
|
19
|
+
};
|
|
20
|
+
/** Fetch a fresh pair+thread handoff from the Vite plugin. */
|
|
21
|
+
export declare function fetchOpenHandoff(endpoint: string, threadId: string, fetchFn?: typeof fetch): Promise<T3FeedbackOpenHandoff>;
|
|
22
|
+
/**
|
|
23
|
+
* Open T3 seamlessly: mint a one-time pairing link, open it in a named window,
|
|
24
|
+
* then navigate that window to the thread deep link.
|
|
25
|
+
*/
|
|
26
|
+
export declare function openInT3Seamless(options: OpenInT3SeamlessOptions): Promise<void>;
|
|
27
|
+
/** True when the click should use the default browser link behavior (new tab modifiers). */
|
|
28
|
+
export declare function shouldBypassSeamlessOpen(event: Pick<MouseEvent, "button" | "metaKey" | "ctrlKey" | "shiftKey" | "altKey">): boolean;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** Named window reused so pair → thread stays in one tab. */
|
|
2
|
+
export const T3_OPEN_WINDOW_NAME = "t3-code";
|
|
3
|
+
/**
|
|
4
|
+
* Delay between opening `/pair#token=…` and navigating to the thread.
|
|
5
|
+
* Long enough for local auto-pair; short enough to feel snappy when already paired.
|
|
6
|
+
*/
|
|
7
|
+
export const T3_PAIR_THEN_THREAD_DELAY_MS = 1800;
|
|
8
|
+
function isHandoff(value) {
|
|
9
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
10
|
+
return false;
|
|
11
|
+
const record = value;
|
|
12
|
+
return (typeof record.pairUrl === "string" &&
|
|
13
|
+
record.pairUrl.trim().length > 0 &&
|
|
14
|
+
typeof record.threadUrl === "string" &&
|
|
15
|
+
record.threadUrl.trim().length > 0);
|
|
16
|
+
}
|
|
17
|
+
/** Fetch a fresh pair+thread handoff from the Vite plugin. */
|
|
18
|
+
export async function fetchOpenHandoff(endpoint, threadId, fetchFn = fetch) {
|
|
19
|
+
const params = new URLSearchParams({ threadId });
|
|
20
|
+
const res = await fetchFn(`${endpoint}/open?${params.toString()}`, {
|
|
21
|
+
headers: { Accept: "application/json" },
|
|
22
|
+
});
|
|
23
|
+
let parsed;
|
|
24
|
+
try {
|
|
25
|
+
parsed = await res.json();
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
parsed = undefined;
|
|
29
|
+
}
|
|
30
|
+
if (!res.ok || !isHandoff(parsed)) {
|
|
31
|
+
const errMsg = parsed &&
|
|
32
|
+
typeof parsed === "object" &&
|
|
33
|
+
"error" in parsed &&
|
|
34
|
+
typeof parsed.error === "string"
|
|
35
|
+
? parsed.error
|
|
36
|
+
: `HTTP ${res.status}`;
|
|
37
|
+
throw new Error(errMsg);
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
pairUrl: parsed.pairUrl.trim(),
|
|
41
|
+
threadUrl: parsed.threadUrl.trim(),
|
|
42
|
+
environmentId: typeof parsed.environmentId === "string"
|
|
43
|
+
? parsed.environmentId.trim()
|
|
44
|
+
: "",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Open T3 seamlessly: mint a one-time pairing link, open it in a named window,
|
|
49
|
+
* then navigate that window to the thread deep link.
|
|
50
|
+
*/
|
|
51
|
+
export async function openInT3Seamless(options) {
|
|
52
|
+
const { threadId, fallbackUrl, endpoint, fetchFn = fetch, openWindow = ((url, target, features) => features === undefined
|
|
53
|
+
? window.open(url, target)
|
|
54
|
+
: window.open(url, target, features)), delayMs = T3_PAIR_THEN_THREAD_DELAY_MS, schedule = setTimeout, } = options;
|
|
55
|
+
let handoff;
|
|
56
|
+
try {
|
|
57
|
+
handoff = await fetchOpenHandoff(endpoint, threadId, fetchFn);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
openWindow(fallbackUrl, T3_OPEN_WINDOW_NAME);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const pairWin = openWindow(handoff.pairUrl, T3_OPEN_WINDOW_NAME);
|
|
64
|
+
const threadUrl = handoff.threadUrl;
|
|
65
|
+
schedule(() => {
|
|
66
|
+
try {
|
|
67
|
+
if (pairWin && !pairWin.closed) {
|
|
68
|
+
pairWin.location.href = threadUrl;
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// Cross-origin or closed — fall through to named reopen.
|
|
74
|
+
}
|
|
75
|
+
openWindow(threadUrl, T3_OPEN_WINDOW_NAME);
|
|
76
|
+
}, delayMs);
|
|
77
|
+
}
|
|
78
|
+
/** True when the click should use the default browser link behavior (new tab modifiers). */
|
|
79
|
+
export function shouldBypassSeamlessOpen(event) {
|
|
80
|
+
return event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
|
|
81
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { FeedbackCapturePlugin, PageContext } from "@rose42/feedback-capture";
|
|
2
|
+
export type FeedbackReportType = "bug" | "feedback" | "suggestion";
|
|
3
|
+
/** Console ring buffer — full diagnostics (bug) only. */
|
|
4
|
+
export declare const CONSOLE_LOGS_REPORT_TYPES: readonly ["bug"];
|
|
5
|
+
/** App version — bug + UX reaction; omitted from lighter suggestions. */
|
|
6
|
+
export declare const VERSION_REPORT_TYPES: readonly ["bug", "feedback"];
|
|
7
|
+
/**
|
|
8
|
+
* Page-context fields kept per report type.
|
|
9
|
+
* - bug (full diagnostics): URL, viewport, UA, capturedAt
|
|
10
|
+
* - feedback (UX reaction): URL, viewport, capturedAt — no UA
|
|
11
|
+
* - suggestion (lighter product input): URL only
|
|
12
|
+
*/
|
|
13
|
+
export type PageContextForReport = {
|
|
14
|
+
url: string;
|
|
15
|
+
userAgent?: string;
|
|
16
|
+
viewport?: PageContext["viewport"];
|
|
17
|
+
capturedAt?: string;
|
|
18
|
+
};
|
|
19
|
+
/** Narrow a full page snapshot to the fields active for `reportType`. */
|
|
20
|
+
export declare function pageContextForReportType(full: PageContext, reportType: FeedbackReportType): PageContextForReport;
|
|
21
|
+
/** Which collect builtins are registered (drives report-type hint text). */
|
|
22
|
+
export type ReportTypeHintCapabilities = {
|
|
23
|
+
pageContext: boolean;
|
|
24
|
+
consoleLogs: boolean;
|
|
25
|
+
/** Screenshot gallery / attachment uploads. */
|
|
26
|
+
attachments: boolean;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Panel hint for a report type, derived from registered plugins + page-context
|
|
30
|
+
* field filtering (same rules as collect).
|
|
31
|
+
*/
|
|
32
|
+
export declare function buildReportTypeHint(reportType: FeedbackReportType, caps: ReportTypeHintCapabilities): string;
|
|
33
|
+
/** True when a plugin should collect for the given report type. */
|
|
34
|
+
export declare function pluginAppliesToReportType(plugin: {
|
|
35
|
+
reportTypes?: readonly FeedbackReportType[];
|
|
36
|
+
}, reportType: FeedbackReportType): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Wrap `collect` so the plugin is omitted from the envelope when the selected
|
|
39
|
+
* report type is outside `reportTypes`.
|
|
40
|
+
*/
|
|
41
|
+
export declare function gatePluginByReportType<P extends FeedbackCapturePlugin & {
|
|
42
|
+
reportTypes?: readonly FeedbackReportType[];
|
|
43
|
+
}>(plugin: P, getReportType: () => FeedbackReportType): P;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/** Console ring buffer — full diagnostics (bug) only. */
|
|
2
|
+
export const CONSOLE_LOGS_REPORT_TYPES = ["bug"];
|
|
3
|
+
/** App version — bug + UX reaction; omitted from lighter suggestions. */
|
|
4
|
+
export const VERSION_REPORT_TYPES = [
|
|
5
|
+
"bug",
|
|
6
|
+
"feedback",
|
|
7
|
+
];
|
|
8
|
+
const REPORT_TYPE_PROFILE_TITLE = {
|
|
9
|
+
bug: "Full diagnostics",
|
|
10
|
+
feedback: "UX reaction",
|
|
11
|
+
suggestion: "Lighter product input",
|
|
12
|
+
};
|
|
13
|
+
/** Narrow a full page snapshot to the fields active for `reportType`. */
|
|
14
|
+
export function pageContextForReportType(full, reportType) {
|
|
15
|
+
if (reportType === "suggestion") {
|
|
16
|
+
return { url: full.url };
|
|
17
|
+
}
|
|
18
|
+
if (reportType === "feedback") {
|
|
19
|
+
return {
|
|
20
|
+
url: full.url,
|
|
21
|
+
viewport: full.viewport,
|
|
22
|
+
capturedAt: full.capturedAt,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
url: full.url,
|
|
27
|
+
userAgent: full.userAgent,
|
|
28
|
+
viewport: full.viewport,
|
|
29
|
+
capturedAt: full.capturedAt,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function formatHintList(items, joiner = "and") {
|
|
33
|
+
if (items.length === 0)
|
|
34
|
+
return "";
|
|
35
|
+
if (items.length === 1)
|
|
36
|
+
return items[0];
|
|
37
|
+
if (items.length === 2)
|
|
38
|
+
return `${items[0]} ${joiner} ${items[1]}`;
|
|
39
|
+
return `${items.slice(0, -1).join(", ")}, ${joiner} ${items.at(-1)}`;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Panel hint for a report type, derived from registered plugins + page-context
|
|
43
|
+
* field filtering (same rules as collect).
|
|
44
|
+
*/
|
|
45
|
+
export function buildReportTypeHint(reportType, caps) {
|
|
46
|
+
const included = [];
|
|
47
|
+
const omitted = [];
|
|
48
|
+
if (caps.pageContext) {
|
|
49
|
+
included.push("URL");
|
|
50
|
+
const fields = pageContextForReportType({
|
|
51
|
+
url: "x",
|
|
52
|
+
userAgent: "x",
|
|
53
|
+
viewport: { width: 1, height: 1, devicePixelRatio: 1 },
|
|
54
|
+
capturedAt: "x",
|
|
55
|
+
}, reportType);
|
|
56
|
+
if (fields.viewport)
|
|
57
|
+
included.push("viewport");
|
|
58
|
+
else
|
|
59
|
+
omitted.push("viewport");
|
|
60
|
+
if (fields.userAgent)
|
|
61
|
+
included.push("UA");
|
|
62
|
+
else
|
|
63
|
+
omitted.push("UA");
|
|
64
|
+
}
|
|
65
|
+
if (caps.consoleLogs) {
|
|
66
|
+
if (pluginAppliesToReportType({ reportTypes: CONSOLE_LOGS_REPORT_TYPES }, reportType)) {
|
|
67
|
+
included.push("console logs");
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
omitted.push("console");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (caps.attachments) {
|
|
74
|
+
included.push("attachments");
|
|
75
|
+
}
|
|
76
|
+
const title = REPORT_TYPE_PROFILE_TITLE[reportType];
|
|
77
|
+
const list = formatHintList(included);
|
|
78
|
+
if (!list)
|
|
79
|
+
return `${title}.`;
|
|
80
|
+
if (reportType === "suggestion") {
|
|
81
|
+
return `${title}: ${list} only.`;
|
|
82
|
+
}
|
|
83
|
+
if (omitted.length > 0) {
|
|
84
|
+
return `${title}: ${list} - no ${formatHintList(omitted, "or")}.`;
|
|
85
|
+
}
|
|
86
|
+
return `${title}: ${list}.`;
|
|
87
|
+
}
|
|
88
|
+
/** True when a plugin should collect for the given report type. */
|
|
89
|
+
export function pluginAppliesToReportType(plugin, reportType) {
|
|
90
|
+
const allowed = plugin.reportTypes;
|
|
91
|
+
if (!allowed || allowed.length === 0)
|
|
92
|
+
return true;
|
|
93
|
+
return allowed.includes(reportType);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Wrap `collect` so the plugin is omitted from the envelope when the selected
|
|
97
|
+
* report type is outside `reportTypes`.
|
|
98
|
+
*/
|
|
99
|
+
export function gatePluginByReportType(plugin, getReportType) {
|
|
100
|
+
if (!plugin.reportTypes?.length)
|
|
101
|
+
return plugin;
|
|
102
|
+
return {
|
|
103
|
+
...plugin,
|
|
104
|
+
collect: async () => {
|
|
105
|
+
if (!pluginAppliesToReportType(plugin, getReportType()))
|
|
106
|
+
return undefined;
|
|
107
|
+
return plugin.collect();
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type SelectBurstFramesCopy = {
|
|
2
|
+
hint: string;
|
|
3
|
+
confirmLabel: string;
|
|
4
|
+
cancelLabel: string;
|
|
5
|
+
};
|
|
6
|
+
export type SelectBurstFramesOptions = {
|
|
7
|
+
parent: HTMLElement;
|
|
8
|
+
frames: Array<{
|
|
9
|
+
dataUrl: string;
|
|
10
|
+
}>;
|
|
11
|
+
/** Max frames the user may select (remaining gallery slots). */
|
|
12
|
+
maxSelect: number;
|
|
13
|
+
/** Indices pre-checked (clamped to maxSelect). Default: first maxSelect. */
|
|
14
|
+
initiallySelected?: number[];
|
|
15
|
+
copy?: Partial<SelectBurstFramesCopy>;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Fullscreen filmstrip: toggle frames, confirm returns selected data URLs (time order).
|
|
19
|
+
* Cancel / Esc → `null`.
|
|
20
|
+
*/
|
|
21
|
+
export declare function selectBurstFrames(options: SelectBurstFramesOptions): Promise<string[] | null>;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
const DEFAULT_COPY = {
|
|
2
|
+
hint: "Select frames to attach. Click to toggle.",
|
|
3
|
+
confirmLabel: "Add selected",
|
|
4
|
+
cancelLabel: "Cancel",
|
|
5
|
+
};
|
|
6
|
+
function escapeHtml(value) {
|
|
7
|
+
return value
|
|
8
|
+
.replaceAll("&", "&")
|
|
9
|
+
.replaceAll("<", "<")
|
|
10
|
+
.replaceAll(">", ">")
|
|
11
|
+
.replaceAll('"', """);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Fullscreen filmstrip: toggle frames, confirm returns selected data URLs (time order).
|
|
15
|
+
* Cancel / Esc → `null`.
|
|
16
|
+
*/
|
|
17
|
+
export function selectBurstFrames(options) {
|
|
18
|
+
const copy = { ...DEFAULT_COPY, ...options.copy };
|
|
19
|
+
const maxSelect = Math.max(0, Math.floor(options.maxSelect));
|
|
20
|
+
const selected = new Set();
|
|
21
|
+
if (options.initiallySelected?.length) {
|
|
22
|
+
for (const index of options.initiallySelected) {
|
|
23
|
+
if (index >= 0 && index < options.frames.length && selected.size < maxSelect) {
|
|
24
|
+
selected.add(index);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
const pre = Math.min(maxSelect, options.frames.length);
|
|
30
|
+
for (let i = 0; i < pre; i++)
|
|
31
|
+
selected.add(i);
|
|
32
|
+
}
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
const overlay = document.createElement("div");
|
|
35
|
+
overlay.className = "t3f-burst-overlay";
|
|
36
|
+
overlay.setAttribute("role", "dialog");
|
|
37
|
+
overlay.setAttribute("aria-label", "Select frames");
|
|
38
|
+
overlay.innerHTML = `
|
|
39
|
+
<div class="t3f-burst-card">
|
|
40
|
+
<div class="t3f-burst-hint">${escapeHtml(copy.hint)}</div>
|
|
41
|
+
<div class="t3f-burst-meta"></div>
|
|
42
|
+
<div class="t3f-burst-strip"></div>
|
|
43
|
+
<div class="t3f-burst-actions">
|
|
44
|
+
<button type="button" class="t3f-btn" data-act="burst-cancel">${escapeHtml(copy.cancelLabel)}</button>
|
|
45
|
+
<button type="button" class="t3f-btn primary" data-act="burst-ok">${escapeHtml(copy.confirmLabel)}</button>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
`;
|
|
49
|
+
const strip = overlay.querySelector(".t3f-burst-strip");
|
|
50
|
+
const meta = overlay.querySelector(".t3f-burst-meta");
|
|
51
|
+
const okBtn = overlay.querySelector('[data-act="burst-ok"]');
|
|
52
|
+
const syncMeta = () => {
|
|
53
|
+
meta.textContent = `${selected.size} / ${maxSelect} selected`;
|
|
54
|
+
okBtn.disabled = selected.size === 0;
|
|
55
|
+
};
|
|
56
|
+
const render = () => {
|
|
57
|
+
strip.replaceChildren();
|
|
58
|
+
options.frames.forEach((frame, index) => {
|
|
59
|
+
const btn = document.createElement("button");
|
|
60
|
+
btn.type = "button";
|
|
61
|
+
btn.className = "t3f-burst-item";
|
|
62
|
+
btn.setAttribute("aria-pressed", selected.has(index) ? "true" : "false");
|
|
63
|
+
const img = document.createElement("img");
|
|
64
|
+
img.src = frame.dataUrl;
|
|
65
|
+
img.alt = `Frame ${index + 1}`;
|
|
66
|
+
btn.append(img);
|
|
67
|
+
btn.addEventListener("click", () => {
|
|
68
|
+
if (selected.has(index))
|
|
69
|
+
selected.delete(index);
|
|
70
|
+
else if (selected.size < maxSelect)
|
|
71
|
+
selected.add(index);
|
|
72
|
+
render();
|
|
73
|
+
});
|
|
74
|
+
strip.append(btn);
|
|
75
|
+
});
|
|
76
|
+
syncMeta();
|
|
77
|
+
};
|
|
78
|
+
const finish = (urls) => {
|
|
79
|
+
window.removeEventListener("keydown", onKeyDown, true);
|
|
80
|
+
overlay.remove();
|
|
81
|
+
resolve(urls);
|
|
82
|
+
};
|
|
83
|
+
const onKeyDown = (event) => {
|
|
84
|
+
if (event.key === "Escape") {
|
|
85
|
+
event.preventDefault();
|
|
86
|
+
event.stopImmediatePropagation();
|
|
87
|
+
finish(null);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
overlay.querySelector('[data-act="burst-cancel"]')?.addEventListener("click", () => {
|
|
91
|
+
finish(null);
|
|
92
|
+
});
|
|
93
|
+
okBtn.addEventListener("click", () => {
|
|
94
|
+
const urls = [...selected].sort((a, b) => a - b).map((i) => options.frames[i].dataUrl);
|
|
95
|
+
finish(urls);
|
|
96
|
+
});
|
|
97
|
+
window.addEventListener("keydown", onKeyDown, true);
|
|
98
|
+
render();
|
|
99
|
+
options.parent.appendChild(overlay);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** How long a prior send stays the soft-default “Continue” target. */
|
|
2
|
+
export declare const SESSION_THREAD_TTL_MS: number;
|
|
3
|
+
export type SessionThreadReportType = "bug" | "feedback" | "suggestion";
|
|
4
|
+
/** Persisted sticky-within-route thread metadata (ask / session-route reuse). */
|
|
5
|
+
export type SessionThreadRecord = {
|
|
6
|
+
threadId: string;
|
|
7
|
+
threadTitle: string;
|
|
8
|
+
reportType?: SessionThreadReportType;
|
|
9
|
+
lastSentAt: number;
|
|
10
|
+
};
|
|
11
|
+
export type ThreadDestination = "new" | "continue";
|
|
12
|
+
/** sessionStorage key for the sticky-within-route thread record. */
|
|
13
|
+
export declare function sessionThreadStorageKey(pathname: string): string;
|
|
14
|
+
export declare function readSessionThreadRecord(pathname: string): SessionThreadRecord | undefined;
|
|
15
|
+
/** @deprecated Prefer {@link readSessionThreadRecord}. */
|
|
16
|
+
export declare function readSessionThreadId(pathname: string): string | undefined;
|
|
17
|
+
export declare function writeSessionThreadRecord(pathname: string, record: SessionThreadRecord): void;
|
|
18
|
+
/** @deprecated Prefer {@link writeSessionThreadRecord}. */
|
|
19
|
+
export declare function writeSessionThreadId(pathname: string, threadId: string): void;
|
|
20
|
+
/**
|
|
21
|
+
* Soft default for New vs Continue.
|
|
22
|
+
* Prefer Continue only when the last send on this route looks like the same job:
|
|
23
|
+
* within TTL and same report type. Never hides Continue when a record exists —
|
|
24
|
+
* callers still offer it; this only picks the default.
|
|
25
|
+
*/
|
|
26
|
+
export declare function defaultThreadDestination(record: SessionThreadRecord | undefined, reportType: SessionThreadReportType, now?: number, ttlMs?: number): ThreadDestination;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const STORAGE_PREFIX = "t3-feedback:thread:";
|
|
2
|
+
/** How long a prior send stays the soft-default “Continue” target. */
|
|
3
|
+
export const SESSION_THREAD_TTL_MS = 15 * 60 * 1000;
|
|
4
|
+
/** sessionStorage key for the sticky-within-route thread record. */
|
|
5
|
+
export function sessionThreadStorageKey(pathname) {
|
|
6
|
+
return `${STORAGE_PREFIX}${pathname || "/"}`;
|
|
7
|
+
}
|
|
8
|
+
function isReportType(value) {
|
|
9
|
+
return value === "bug" || value === "feedback" || value === "suggestion";
|
|
10
|
+
}
|
|
11
|
+
function parseRecord(raw) {
|
|
12
|
+
const trimmed = raw.trim();
|
|
13
|
+
if (!trimmed)
|
|
14
|
+
return undefined;
|
|
15
|
+
if (trimmed.startsWith("{")) {
|
|
16
|
+
try {
|
|
17
|
+
const parsed = JSON.parse(trimmed);
|
|
18
|
+
const threadId = typeof parsed.threadId === "string" ? parsed.threadId.trim() : "";
|
|
19
|
+
if (!threadId)
|
|
20
|
+
return undefined;
|
|
21
|
+
const threadTitle = typeof parsed.threadTitle === "string" ? parsed.threadTitle.trim() : "";
|
|
22
|
+
const lastSentAt = typeof parsed.lastSentAt === "number" && Number.isFinite(parsed.lastSentAt)
|
|
23
|
+
? parsed.lastSentAt
|
|
24
|
+
: 0;
|
|
25
|
+
return {
|
|
26
|
+
threadId,
|
|
27
|
+
threadTitle,
|
|
28
|
+
lastSentAt,
|
|
29
|
+
...(isReportType(parsed.reportType) ? { reportType: parsed.reportType } : {}),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// Legacy: plain thread id string.
|
|
37
|
+
return {
|
|
38
|
+
threadId: trimmed,
|
|
39
|
+
threadTitle: "",
|
|
40
|
+
lastSentAt: 0,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export function readSessionThreadRecord(pathname) {
|
|
44
|
+
try {
|
|
45
|
+
const value = sessionStorage.getItem(sessionThreadStorageKey(pathname));
|
|
46
|
+
if (!value)
|
|
47
|
+
return undefined;
|
|
48
|
+
return parseRecord(value);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** @deprecated Prefer {@link readSessionThreadRecord}. */
|
|
55
|
+
export function readSessionThreadId(pathname) {
|
|
56
|
+
return readSessionThreadRecord(pathname)?.threadId;
|
|
57
|
+
}
|
|
58
|
+
export function writeSessionThreadRecord(pathname, record) {
|
|
59
|
+
try {
|
|
60
|
+
sessionStorage.setItem(sessionThreadStorageKey(pathname), JSON.stringify({
|
|
61
|
+
threadId: record.threadId,
|
|
62
|
+
threadTitle: record.threadTitle,
|
|
63
|
+
lastSentAt: record.lastSentAt,
|
|
64
|
+
...(record.reportType ? { reportType: record.reportType } : {}),
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// Private mode / quota — reuse is best-effort.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** @deprecated Prefer {@link writeSessionThreadRecord}. */
|
|
72
|
+
export function writeSessionThreadId(pathname, threadId) {
|
|
73
|
+
writeSessionThreadRecord(pathname, {
|
|
74
|
+
threadId,
|
|
75
|
+
threadTitle: "",
|
|
76
|
+
lastSentAt: Date.now(),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Soft default for New vs Continue.
|
|
81
|
+
* Prefer Continue only when the last send on this route looks like the same job:
|
|
82
|
+
* within TTL and same report type. Never hides Continue when a record exists —
|
|
83
|
+
* callers still offer it; this only picks the default.
|
|
84
|
+
*/
|
|
85
|
+
export function defaultThreadDestination(record, reportType, now = Date.now(), ttlMs = SESSION_THREAD_TTL_MS) {
|
|
86
|
+
if (!record)
|
|
87
|
+
return "new";
|
|
88
|
+
if (now - record.lastSentAt > ttlMs)
|
|
89
|
+
return "new";
|
|
90
|
+
if (record.reportType !== reportType)
|
|
91
|
+
return "new";
|
|
92
|
+
return "continue";
|
|
93
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ThreadListItem } from "../types.js";
|
|
2
|
+
/** Soft cap so huge projects stay scrollable without dumping hundreds of DOM nodes. */
|
|
3
|
+
export declare const THREAD_PICKER_LIMIT = 48;
|
|
4
|
+
export type FilterThreadListResult = {
|
|
5
|
+
items: ThreadListItem[];
|
|
6
|
+
/** Total matches before the display limit. */
|
|
7
|
+
matchedCount: number;
|
|
8
|
+
/** Working/open matches before the display limit. */
|
|
9
|
+
openMatchedCount: number;
|
|
10
|
+
};
|
|
11
|
+
/** Default search seed for the continue picker (route-shaped titles). */
|
|
12
|
+
export declare function defaultThreadSearchQuery(pathname: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* Filter + rank threads for the continue picker.
|
|
15
|
+
* Selected id is pinned first; open/working next; then updatedAt desc / title.
|
|
16
|
+
*/
|
|
17
|
+
export declare function filterThreadListItems(items: ThreadListItem[], query: string, options?: {
|
|
18
|
+
selectedId?: string;
|
|
19
|
+
limit?: number;
|
|
20
|
+
}): FilterThreadListResult;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Soft cap so huge projects stay scrollable without dumping hundreds of DOM nodes. */
|
|
2
|
+
export const THREAD_PICKER_LIMIT = 48;
|
|
3
|
+
/** Default search seed for the continue picker (route-shaped titles). */
|
|
4
|
+
export function defaultThreadSearchQuery(pathname) {
|
|
5
|
+
const trimmed = pathname.trim();
|
|
6
|
+
return trimmed.length > 0 ? trimmed : "/";
|
|
7
|
+
}
|
|
8
|
+
function stateRank(state) {
|
|
9
|
+
if (state === "working")
|
|
10
|
+
return 0;
|
|
11
|
+
if (state === "unknown")
|
|
12
|
+
return 1;
|
|
13
|
+
return 2;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Filter + rank threads for the continue picker.
|
|
17
|
+
* Selected id is pinned first; open/working next; then updatedAt desc / title.
|
|
18
|
+
*/
|
|
19
|
+
export function filterThreadListItems(items, query, options = {}) {
|
|
20
|
+
const limit = options.limit ?? THREAD_PICKER_LIMIT;
|
|
21
|
+
const selectedId = options.selectedId?.trim() || undefined;
|
|
22
|
+
const needle = query.trim().toLowerCase();
|
|
23
|
+
const matched = needle
|
|
24
|
+
? items.filter((item) => {
|
|
25
|
+
const title = item.title.toLowerCase();
|
|
26
|
+
const id = item.threadId.toLowerCase();
|
|
27
|
+
return title.includes(needle) || id.includes(needle);
|
|
28
|
+
})
|
|
29
|
+
: [...items];
|
|
30
|
+
matched.sort((a, b) => {
|
|
31
|
+
if (selectedId) {
|
|
32
|
+
if (a.threadId === selectedId && b.threadId !== selectedId)
|
|
33
|
+
return -1;
|
|
34
|
+
if (b.threadId === selectedId && a.threadId !== selectedId)
|
|
35
|
+
return 1;
|
|
36
|
+
}
|
|
37
|
+
const byState = stateRank(a.state) - stateRank(b.state);
|
|
38
|
+
if (byState !== 0)
|
|
39
|
+
return byState;
|
|
40
|
+
const aAt = a.updatedAt ?? "";
|
|
41
|
+
const bAt = b.updatedAt ?? "";
|
|
42
|
+
if (aAt !== bAt)
|
|
43
|
+
return bAt.localeCompare(aAt);
|
|
44
|
+
return a.title.localeCompare(b.title);
|
|
45
|
+
});
|
|
46
|
+
return {
|
|
47
|
+
items: matched.slice(0, limit),
|
|
48
|
+
matchedCount: matched.length,
|
|
49
|
+
openMatchedCount: matched.filter((t) => t.state === "working").length,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { FeedbackTransport } from "@rose42/feedback-capture";
|
|
2
|
+
export type T3FeedbackHttpTransportOptions = {
|
|
3
|
+
url: string;
|
|
4
|
+
/** Called on each send; return a thread id to reuse when present. */
|
|
5
|
+
getThreadId?: () => string | undefined;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* POSTs `{ envelope, threadId? }` to the Vite middleware (ask / session-route reuse).
|
|
9
|
+
* Falls back to a bare envelope when `getThreadId` returns nothing.
|
|
10
|
+
*/
|
|
11
|
+
export declare function createT3FeedbackHttpTransport(options: T3FeedbackHttpTransportOptions): FeedbackTransport;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POSTs `{ envelope, threadId? }` to the Vite middleware (ask / session-route reuse).
|
|
3
|
+
* Falls back to a bare envelope when `getThreadId` returns nothing.
|
|
4
|
+
*/
|
|
5
|
+
export function createT3FeedbackHttpTransport(options) {
|
|
6
|
+
const { url, getThreadId } = options;
|
|
7
|
+
return {
|
|
8
|
+
async send(envelope) {
|
|
9
|
+
const threadId = getThreadId?.();
|
|
10
|
+
const body = threadId !== undefined ? { envelope, threadId } : { envelope };
|
|
11
|
+
const res = await fetch(url, {
|
|
12
|
+
method: "POST",
|
|
13
|
+
headers: { "Content-Type": "application/json" },
|
|
14
|
+
body: JSON.stringify(body),
|
|
15
|
+
});
|
|
16
|
+
let parsed = undefined;
|
|
17
|
+
try {
|
|
18
|
+
parsed = await res.json();
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
parsed = undefined;
|
|
22
|
+
}
|
|
23
|
+
if (!res.ok) {
|
|
24
|
+
const errMsg = parsed &&
|
|
25
|
+
typeof parsed === "object" &&
|
|
26
|
+
"error" in parsed &&
|
|
27
|
+
typeof parsed.error === "string"
|
|
28
|
+
? parsed.error
|
|
29
|
+
: `HTTP ${res.status}`;
|
|
30
|
+
throw new Error(errMsg);
|
|
31
|
+
}
|
|
32
|
+
return parsed;
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|