Skip to content

Commit c6bc7b9

Browse files
committed
refactor(extension): extract sidepanel hooks and utilities from SidepanelApp
Split ~400 lines of inline logic out of SidepanelApp.tsx into focused, reusable modules: - useDragAndDropZone – drag-and-drop event handling - useScrollPinToBottom – scroll-to-bottom pin/button state - useSidepanelContextMenu – runtime context-menu command listener - useTabContextWatcher – tab activation/update watcher - useTitleGeneration – session title generation lifecycle Extracted pure utilities: - utils/dropPayload.ts – DataTransfer parsing helpers - utils/pendingContextCommand.ts – PendingSidepanelContextCommand type/guards - utils/titleGeneration.ts – LLM title generation helpers - isComposingEnterEvent moved to utils/dom.ts
1 parent 7092aa9 commit c6bc7b9

10 files changed

Lines changed: 1104 additions & 873 deletions

app/extension/src/sidepanel/SidepanelApp.tsx

Lines changed: 80 additions & 873 deletions
Large diffs are not rendered by default.
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { useCallback, useRef, useState, type DragEvent } from "react";
2+
3+
import { DROPPABLE_STRING_TYPES } from "../utils/dropPayload";
4+
5+
interface UseDragAndDropZoneOptions {
6+
/**
7+
* Called when the user drops an external payload into the zone. The hook
8+
* manages visual state (drag-over overlay) and filters out internal drags;
9+
* the caller handles the actual payload processing.
10+
*/
11+
onDrop: (dataTransfer: DataTransfer) => void | Promise<void>;
12+
}
13+
14+
export interface DragZoneHandlers {
15+
onDragEnter: (event: DragEvent<HTMLDivElement>) => void;
16+
onDragOver: (event: DragEvent<HTMLDivElement>) => void;
17+
onDragLeave: (event: DragEvent<HTMLDivElement>) => void;
18+
onDrop: (event: DragEvent<HTMLDivElement>) => void;
19+
onDragStartCapture: () => void;
20+
onDragEndCapture: () => void;
21+
}
22+
23+
interface UseDragAndDropZoneResult {
24+
isDraggingOver: boolean;
25+
handlers: DragZoneHandlers;
26+
}
27+
28+
/**
29+
* Owns the drag-over visual state for a drop zone. Distinguishes external
30+
* payloads (files or URL strings) from internal drags within the zone so the
31+
* overlay doesn't flash when moving children around.
32+
*/
33+
export function useDragAndDropZone(
34+
options: UseDragAndDropZoneOptions
35+
): UseDragAndDropZoneResult {
36+
const { onDrop } = options;
37+
38+
const [isDraggingOver, setIsDraggingOver] = useState(false);
39+
const dragDepthRef = useRef(0);
40+
const internalDragRef = useRef(false);
41+
42+
const hasExternalPayload = useCallback(
43+
(event: DragEvent<HTMLDivElement>) => {
44+
if (internalDragRef.current) {
45+
return false;
46+
}
47+
48+
const items = Array.from(event.dataTransfer?.items || []);
49+
if (items.length > 0) {
50+
return items.some(
51+
(item) =>
52+
item.kind === "file" ||
53+
(item.kind === "string" && DROPPABLE_STRING_TYPES.has(item.type))
54+
);
55+
}
56+
57+
return Array.from(event.dataTransfer?.types || []).some(
58+
(type) => type === "Files" || DROPPABLE_STRING_TYPES.has(type)
59+
);
60+
},
61+
[]
62+
);
63+
64+
const handleDragEnter = useCallback(
65+
(event: DragEvent<HTMLDivElement>) => {
66+
if (!hasExternalPayload(event)) return;
67+
event.preventDefault();
68+
dragDepthRef.current += 1;
69+
setIsDraggingOver(true);
70+
},
71+
[hasExternalPayload]
72+
);
73+
74+
const handleDragOver = useCallback(
75+
(event: DragEvent<HTMLDivElement>) => {
76+
if (!hasExternalPayload(event)) return;
77+
event.preventDefault();
78+
event.dataTransfer.dropEffect = "copy";
79+
},
80+
[hasExternalPayload]
81+
);
82+
83+
const handleDragLeave = useCallback(
84+
(event: DragEvent<HTMLDivElement>) => {
85+
if (!hasExternalPayload(event)) return;
86+
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
87+
if (dragDepthRef.current === 0) setIsDraggingOver(false);
88+
},
89+
[hasExternalPayload]
90+
);
91+
92+
const handleDrop = useCallback(
93+
(event: DragEvent<HTMLDivElement>) => {
94+
if (!hasExternalPayload(event)) return;
95+
event.preventDefault();
96+
internalDragRef.current = false;
97+
dragDepthRef.current = 0;
98+
setIsDraggingOver(false);
99+
100+
void onDrop(event.dataTransfer);
101+
},
102+
[hasExternalPayload, onDrop]
103+
);
104+
105+
const handleInternalDragStartCapture = useCallback(() => {
106+
internalDragRef.current = true;
107+
}, []);
108+
109+
const handleInternalDragEndCapture = useCallback(() => {
110+
internalDragRef.current = false;
111+
dragDepthRef.current = 0;
112+
setIsDraggingOver(false);
113+
}, []);
114+
115+
return {
116+
isDraggingOver,
117+
handlers: {
118+
onDragEnter: handleDragEnter,
119+
onDragOver: handleDragOver,
120+
onDragLeave: handleDragLeave,
121+
onDrop: handleDrop,
122+
onDragStartCapture: handleInternalDragStartCapture,
123+
onDragEndCapture: handleInternalDragEndCapture,
124+
},
125+
};
126+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
2+
3+
import {
4+
isScrollPinnedToBottom,
5+
shouldShowScrollToBottomButton,
6+
} from "../utils/scrollToBottom";
7+
8+
interface UseScrollPinToBottomOptions<T> {
9+
/** The message list. A new reference triggers the auto-scroll effect. */
10+
messages: T[];
11+
/** Pixels from the bottom considered "pinned". */
12+
thresholdPx: number;
13+
}
14+
15+
interface UseScrollPinToBottomResult {
16+
scrollContainerRef: RefObject<HTMLDivElement>;
17+
messagesEndRef: RefObject<HTMLDivElement>;
18+
showScrollToBottom: boolean;
19+
handleScroll: () => void;
20+
scrollToBottom: (behavior?: ScrollBehavior) => void;
21+
}
22+
23+
/**
24+
* Keeps the message list scrolled to the bottom while the user hasn't
25+
* manually scrolled away. Tracks the pinned state in a ref (so re-renders
26+
* don't clobber it) and exposes a toggle button indicator when the user
27+
* has scrolled up far enough.
28+
*/
29+
export function useScrollPinToBottom<T>(
30+
options: UseScrollPinToBottomOptions<T>
31+
): UseScrollPinToBottomResult {
32+
const { messages, thresholdPx } = options;
33+
34+
const scrollContainerRef = useRef<HTMLDivElement>(null);
35+
const messagesEndRef = useRef<HTMLDivElement>(null);
36+
const pinnedRef = useRef(true);
37+
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
38+
39+
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
40+
pinnedRef.current = true;
41+
setShowScrollToBottom(false);
42+
messagesEndRef.current?.scrollIntoView({ block: "end", behavior });
43+
}, []);
44+
45+
const handleScroll = useCallback(() => {
46+
const scrollContainer = scrollContainerRef.current;
47+
if (!scrollContainer) return;
48+
49+
const distanceToBottom =
50+
scrollContainer.scrollHeight -
51+
scrollContainer.scrollTop -
52+
scrollContainer.clientHeight;
53+
const pinned = isScrollPinnedToBottom(distanceToBottom, thresholdPx);
54+
55+
pinnedRef.current = pinned;
56+
setShowScrollToBottom(
57+
shouldShowScrollToBottomButton(messages.length, pinned)
58+
);
59+
}, [messages.length, thresholdPx]);
60+
61+
useEffect(() => {
62+
if (messages.length === 0) {
63+
pinnedRef.current = true;
64+
setShowScrollToBottom(false);
65+
return;
66+
}
67+
68+
if (!pinnedRef.current) {
69+
setShowScrollToBottom(
70+
shouldShowScrollToBottomButton(messages.length, false)
71+
);
72+
return;
73+
}
74+
75+
const frame = window.requestAnimationFrame(() => scrollToBottom("auto"));
76+
return () => window.cancelAnimationFrame(frame);
77+
}, [messages, scrollToBottom]);
78+
79+
return {
80+
scrollContainerRef,
81+
messagesEndRef,
82+
showScrollToBottom,
83+
handleScroll,
84+
scrollToBottom,
85+
};
86+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { useEffect, useRef } from "react";
2+
3+
import {
4+
isPendingSidepanelContextCommand,
5+
type PendingSidepanelContextCommand,
6+
} from "../utils/pendingContextCommand";
7+
8+
type CommandHandler = (
9+
commands: PendingSidepanelContextCommand[]
10+
) => Promise<string[]>;
11+
12+
/**
13+
* Wires up the two sources that feed pending context-menu commands into the
14+
* sidepanel:
15+
*
16+
* 1. A runtime message listener for commands dispatched while this sidepanel
17+
* is already open.
18+
* 2. A one-shot consumption of any pending commands enqueued by the
19+
* background page before this window opened.
20+
*
21+
* The handler is stashed in a ref so the subscription stays mount-once even
22+
* when the caller passes a fresh callback on every render. Re-subscribing
23+
* would otherwise double-consume the background-page queue.
24+
*/
25+
export function useSidepanelContextMenu(handleCommands: CommandHandler) {
26+
const handlerRef = useRef(handleCommands);
27+
28+
useEffect(() => {
29+
handlerRef.current = handleCommands;
30+
}, [handleCommands]);
31+
32+
useEffect(() => {
33+
let cancelled = false;
34+
35+
const handleRuntimeMessage = (
36+
message: unknown,
37+
_sender: unknown,
38+
sendResponse: (response?: unknown) => void
39+
) => {
40+
const typedMessage = message as
41+
| {
42+
type?: string;
43+
payload?: { command?: unknown };
44+
}
45+
| undefined;
46+
if (typedMessage?.type !== "sidepanel_context_menu_command") {
47+
return undefined;
48+
}
49+
50+
const command = typedMessage.payload?.command;
51+
if (!isPendingSidepanelContextCommand(command)) {
52+
sendResponse({
53+
success: false,
54+
error: "Invalid sidepanel context command.",
55+
});
56+
return undefined;
57+
}
58+
59+
void handlerRef
60+
.current([command])
61+
.then((completedIds) => {
62+
const completed = completedIds.includes(command.id);
63+
sendResponse({
64+
success: completed,
65+
commandId: completed ? command.id : null,
66+
});
67+
})
68+
.catch((error) => {
69+
console.error(
70+
"[useSidepanelContextMenu] Failed to handle command",
71+
error
72+
);
73+
sendResponse({
74+
success: false,
75+
error:
76+
(error as Error)?.message || "Failed to process command",
77+
});
78+
});
79+
80+
return true;
81+
};
82+
83+
const consumePending = async () => {
84+
try {
85+
const currentWindow = await chrome.windows.getCurrent();
86+
if (cancelled || typeof currentWindow.id !== "number") {
87+
return;
88+
}
89+
90+
const response = (await chrome.runtime.sendMessage({
91+
type: "consume_pending_sidepanel_context_commands",
92+
payload: {
93+
windowId: currentWindow.id,
94+
},
95+
})) as unknown as { commands?: unknown } | undefined;
96+
const pendingCommands = Array.isArray(response?.commands)
97+
? response.commands.filter(isPendingSidepanelContextCommand)
98+
: [];
99+
100+
if (!cancelled && pendingCommands.length > 0) {
101+
await handlerRef.current(pendingCommands);
102+
}
103+
} catch (error) {
104+
if (!cancelled) {
105+
console.error(
106+
"[useSidepanelContextMenu] Failed to consume pending commands",
107+
error
108+
);
109+
}
110+
}
111+
};
112+
113+
chrome.runtime.onMessage.addListener(handleRuntimeMessage);
114+
void consumePending();
115+
116+
return () => {
117+
cancelled = true;
118+
chrome.runtime.onMessage.removeListener(handleRuntimeMessage);
119+
};
120+
}, []);
121+
}

0 commit comments

Comments
 (0)