Skip to content

Commit 3212a58

Browse files
committed
feat: implement file input attachment for chat context in Grok, Perplexity, and Gemini; add fetch interceptor for Gemini
1 parent 6c7d448 commit 3212a58

8 files changed

Lines changed: 192 additions & 104 deletions

File tree

src/components/HacklmIcon.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,12 @@
99
import React from "react";
1010

1111
const iconUrl = (px: 16 | 48 | 128) => {
12-
if (typeof chrome !== "undefined" && chrome.runtime?.getURL) {
13-
return chrome.runtime.getURL(`icons/icon${px}.png`);
12+
try {
13+
if (typeof chrome !== "undefined" && chrome.runtime?.getURL) {
14+
return chrome.runtime.getURL(`icons/icon${px}.png`);
15+
}
16+
} catch {
17+
// Extension context invalidated — fall through to relative path
1418
}
1519
return `/icons/icon${px}.png`;
1620
};

src/content-scripts/FloatingDial.tsx

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ export const FloatingDial: React.FC<Props> = ({
105105
const [threads, setThreads] = useState<ThreadMeta[]>([]);
106106
const [loadingIdx, setLoadingIdx] = useState(false);
107107
const [injecting, setInjecting] = useState<string | null>(null);
108+
const [injectProgress, setInjectProgress] = useState(0);
109+
const injectTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
108110

109111
/* search and filter */
110112
const [searchQuery, setSearchQuery] = useState("");
@@ -216,16 +218,29 @@ export const FloatingDial: React.FC<Props> = ({
216218

217219
const handleRestore = useCallback(async (threadId: string) => {
218220
setInjecting(threadId);
221+
setInjectProgress(0);
222+
223+
// Ease progress from 0 → 90 while waiting (each tick: +6% of remaining gap)
224+
injectTimerRef.current = setInterval(() => {
225+
setInjectProgress((p) => p + (90 - p) * 0.06);
226+
}, 80);
227+
219228
try {
220229
const prompt = await sendMessage<string>({
221230
type: "GENERATE_SEED_PROMPT",
222231
threadId,
223232
});
233+
// Snap to 100%, then close
234+
if (injectTimerRef.current) { clearInterval(injectTimerRef.current); injectTimerRef.current = null; }
235+
setInjectProgress(100);
224236
onInject(prompt);
237+
await new Promise((r) => setTimeout(r, 300));
225238
setRestoreOpen(false);
226239
} catch {
240+
if (injectTimerRef.current) { clearInterval(injectTimerRef.current); injectTimerRef.current = null; }
227241
} finally {
228242
setInjecting(null);
243+
setInjectProgress(0);
229244
}
230245
}, [onInject]);
231246

@@ -833,7 +848,23 @@ export const FloatingDial: React.FC<Props> = ({
833848
borderRadius: 4
834849
}}>
835850
{injecting === t.id
836-
? <><Loader2 size={12} style={{ animation: "spin 1s linear infinite" }} /> Inj…</>
851+
? (
852+
<span style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 3, minWidth: 64 }}>
853+
<span style={{ fontSize: 10, color: "#fb631b", fontFamily: '"JetBrains Mono", monospace' }}>
854+
{Math.round(injectProgress)}%
855+
</span>
856+
<span style={{ width: 64, height: 4, background: "rgba(251,99,27,0.18)", borderRadius: 2, overflow: "hidden", display: "block" }}>
857+
<span style={{
858+
display: "block",
859+
height: "100%",
860+
width: `${injectProgress}%`,
861+
background: "#fb631b",
862+
borderRadius: 2,
863+
transition: "width 0.08s linear",
864+
}} />
865+
</span>
866+
</span>
867+
)
837868
: <><ArchiveRestore size={12} /> Restore</>}
838869
</span>
839870
</button>

src/content-scripts/claude.tsx

Lines changed: 10 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,43 +14,33 @@ function getConversationId(): string | null {
1414
return match?.[1] ?? null;
1515
}
1616

17-
function attachViaPasteEvent(text: string): boolean {
18-
const editor = document.querySelector<HTMLElement>(
19-
'div[contenteditable="true"].ProseMirror'
20-
);
21-
if (!editor) return false;
17+
function attachViaFileInput(text: string): boolean {
18+
const fileInput = document.querySelector<HTMLInputElement>("input[type='file']");
19+
if (!fileInput) return false;
2220

2321
const file = new File([text], "AI_Chat_Backup_Context_Seed.txt", {
2422
type: "text/plain",
23+
lastModified: Date.now(),
2524
});
2625

2726
const dt = new DataTransfer();
2827
dt.items.add(file);
29-
30-
const pasteEvent = new ClipboardEvent("paste", {
31-
bubbles: true,
32-
cancelable: true,
33-
clipboardData: dt,
34-
});
35-
36-
editor.focus();
37-
editor.dispatchEvent(pasteEvent);
28+
fileInput.files = dt.files;
29+
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
3830
return true;
3931
}
4032

4133
function injectIntoTextarea(text: string) {
42-
const tryAttach = () => attachViaPasteEvent(text);
43-
44-
if (tryAttach()) return;
34+
if (attachViaFileInput(text)) return;
4535

46-
// Retry dynamically, but do NOT fall back to pasting raw text.
36+
// File input not yet in DOM – retry for up to 5 s
4737
let attempts = 0;
4838
const interval = setInterval(() => {
4939
attempts++;
50-
if (tryAttach() || attempts > 10) {
40+
if (attachViaFileInput(text) || attempts > 20) {
5141
clearInterval(interval);
5242
}
53-
}, 200);
43+
}, 250);
5444
}
5545

5646
mountWidget(

src/content-scripts/gemini-main.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/* ──────────────────────────────────────────────
2+
* Gemini MAIN-world fetch interceptor
3+
*
4+
* Declared in the manifest with world: "MAIN"
5+
* and run_at: "document_start" so it runs
6+
* directly in the page's JS context — no
7+
* <script> tag injection, no CSP violations.
8+
*
9+
* Intercepts /batchexecute responses and
10+
* forwards them to the ISOLATED world via
11+
* window.postMessage.
12+
* ────────────────────────────────────────────── */
13+
14+
const _fetch = window.fetch;
15+
16+
window.fetch = async function (...args: Parameters<typeof fetch>) {
17+
const response = await _fetch.apply(this, args);
18+
19+
try {
20+
const url =
21+
typeof args[0] === "string"
22+
? args[0]
23+
: (args[0] as Request)?.url ?? "";
24+
25+
if (url.includes("batchexecute")) {
26+
const clone = response.clone();
27+
clone
28+
.text()
29+
.then((text) => {
30+
window.postMessage(
31+
{
32+
type: "__HACKLM_MIGRATE_GEMINI_INTERCEPT__",
33+
url,
34+
body: text,
35+
},
36+
"*"
37+
);
38+
})
39+
.catch(() => {});
40+
}
41+
} catch (_) {}
42+
43+
return response;
44+
};

src/content-scripts/gemini.tsx

Lines changed: 32 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,18 @@
11
/* ──────────────────────────────────────────────
2-
* Content-script – Gemini
2+
* Content-script – Gemini (ISOLATED world)
33
*
4-
* Two responsibilities:
5-
* 1. Monkey-patch window.fetch in MAIN world to
6-
* intercept Google's batch RPC responses.
4+
* Responsibilities:
5+
* 1. Listen for postMessage from the MAIN-world
6+
* fetch interceptor (gemini-main.ts) and
7+
* forward intercepted chats to the background.
78
* 2. Inject Save + Restore overlay buttons.
8-
*
9-
* NOTE: CRXJS will inject this as an ISOLATED
10-
* content script. To run in MAIN world, we
11-
* inject a <script> element. Communication
12-
* goes through window.postMessage.
139
* ────────────────────────────────────────────── */
1410

1511
import React from "react";
1612
import { mountWidget } from "./mount";
1713
import { FloatingDial } from "./FloatingDial";
1814

19-
/* ── 1. MAIN-world fetch interceptor ──────────────────────── */
20-
21-
const interceptorCode = `
22-
(function() {
23-
const _fetch = window.fetch;
24-
window.fetch = async function(...args) {
25-
const response = await _fetch.apply(this, args);
26-
27-
try {
28-
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url ?? '';
29-
// Google Gemini uses /batchexecute for its RPC calls
30-
if (url.includes('batchexecute')) {
31-
const clone = response.clone();
32-
clone.text().then(text => {
33-
window.postMessage({
34-
type: '__HACKLM_MIGRATE_GEMINI_INTERCEPT__',
35-
url,
36-
body: text,
37-
}, '*');
38-
}).catch(() => {});
39-
}
40-
} catch (_) {}
41-
42-
return response;
43-
};
44-
})();
45-
`;
46-
47-
// Inject into MAIN world
48-
const script = document.createElement("script");
49-
script.textContent = interceptorCode;
50-
document.documentElement.prepend(script);
51-
script.remove();
52-
53-
/* ── Listen for intercepted data ──────────────────────────── */
15+
/* ── 1. Listen for intercepted data from MAIN world ────────── */
5416

5517
window.addEventListener("message", (event) => {
5618
if (event.data?.type !== "__HACKLM_MIGRATE_GEMINI_INTERCEPT__") return;
@@ -122,19 +84,33 @@ function getConversationId(): string | null {
12284
return match?.[2] ?? null;
12385
}
12486

125-
function injectIntoTextarea(text: string) {
126-
const input = document.querySelector<HTMLElement>(
127-
"[contenteditable='true'], textarea, .ql-editor"
128-
);
129-
if (!input) return;
87+
function attachViaFileInput(text: string): boolean {
88+
const fileInput = document.querySelector<HTMLInputElement>("input[type='file']");
89+
if (!fileInput) return false;
13090

131-
if (input.tagName === "TEXTAREA") {
132-
(input as HTMLTextAreaElement).value = text;
133-
} else {
134-
input.innerText = text;
135-
}
136-
input.dispatchEvent(new Event("input", { bubbles: true }));
137-
input.focus();
91+
const file = new File([text], "AI_Chat_Backup_Context_Seed.txt", {
92+
type: "text/plain",
93+
lastModified: Date.now(),
94+
});
95+
96+
const dt = new DataTransfer();
97+
dt.items.add(file);
98+
fileInput.files = dt.files;
99+
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
100+
return true;
101+
}
102+
103+
function injectIntoTextarea(text: string) {
104+
if (attachViaFileInput(text)) return;
105+
106+
// File input not yet in DOM – retry for up to 5 s
107+
let attempts = 0;
108+
const interval = setInterval(() => {
109+
attempts++;
110+
if (attachViaFileInput(text) || attempts > 20) {
111+
clearInterval(interval);
112+
}
113+
}, 250);
138114
}
139115

140116
mountWidget(

src/content-scripts/grok.tsx

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,42 @@ function getConversationId(): string | null {
1616
return match?.[1] ?? null;
1717
}
1818

19+
/**
20+
* Attach the seed as a file upload so Grok receives it as an attachment
21+
* rather than pasting potentially huge raw text into the input (which hangs
22+
* the page). Grok renders a hidden <input type="file"> in the composer;
23+
* we programmatically assign a File to it via DataTransfer and fire change.
24+
*/
25+
function attachViaFileInput(text: string): boolean {
26+
const fileInput = document.querySelector<HTMLInputElement>("input[type='file']");
27+
if (!fileInput) return false;
28+
29+
const file = new File([text], "AI_Chat_Backup_Context_Seed.txt", {
30+
type: "text/plain",
31+
lastModified: Date.now(),
32+
});
33+
34+
const dt = new DataTransfer();
35+
dt.items.add(file);
36+
fileInput.files = dt.files;
37+
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
38+
return true;
39+
}
40+
1941
function injectIntoTextarea(text: string) {
20-
const input = document.querySelector<HTMLElement>(
21-
"textarea, [contenteditable='true']"
22-
);
23-
if (!input) return;
24-
25-
if (input.tagName === "TEXTAREA") {
26-
(input as HTMLTextAreaElement).value = text;
27-
} else {
28-
input.innerText = text;
29-
}
30-
input.dispatchEvent(new Event("input", { bubbles: true }));
31-
input.focus();
42+
const tryAttach = () => attachViaFileInput(text);
43+
44+
if (tryAttach()) return;
45+
46+
// Retry – the file input may not be in the DOM until the user has focused
47+
// the composer at least once.
48+
let attempts = 0;
49+
const interval = setInterval(() => {
50+
attempts++;
51+
if (tryAttach() || attempts > 20) {
52+
clearInterval(interval);
53+
}
54+
}, 250);
3255
}
3356

3457
mountWidget(

src/content-scripts/perplexity.tsx

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,20 +16,33 @@ function getConversationId(): string | null {
1616
return match?.[1] ?? null;
1717
}
1818

19+
function attachViaFileInput(text: string): boolean {
20+
const fileInput = document.querySelector<HTMLInputElement>("input[type='file']");
21+
if (!fileInput) return false;
22+
23+
const file = new File([text], "AI_Chat_Backup_Context_Seed.txt", {
24+
type: "text/plain",
25+
lastModified: Date.now(),
26+
});
27+
28+
const dt = new DataTransfer();
29+
dt.items.add(file);
30+
fileInput.files = dt.files;
31+
fileInput.dispatchEvent(new Event("change", { bubbles: true }));
32+
return true;
33+
}
34+
1935
function injectIntoTextarea(text: string) {
20-
const textarea = document.querySelector<HTMLTextAreaElement>(
21-
"textarea, [contenteditable='true']"
22-
);
23-
if (!textarea) return;
24-
25-
if (textarea.tagName === "TEXTAREA") {
26-
textarea.value = text;
27-
textarea.dispatchEvent(new Event("input", { bubbles: true }));
28-
} else {
29-
textarea.innerText = text;
30-
textarea.dispatchEvent(new Event("input", { bubbles: true }));
31-
}
32-
textarea.focus();
36+
if (attachViaFileInput(text)) return;
37+
38+
// File input not yet in DOM – retry for up to 5 s (Perplexity loads it lazily)
39+
let attempts = 0;
40+
const interval = setInterval(() => {
41+
attempts++;
42+
if (attachViaFileInput(text) || attempts > 20) {
43+
clearInterval(interval);
44+
}
45+
}, 250);
3346
}
3447

3548
mountWidget(

0 commit comments

Comments
 (0)