| 1 |
import { useQuickEditStore } from '../state/store'; |
| 2 |
import { buildAgentBlockDescriptor } from './agent-block-descriptor'; |
| 3 |
|
| 4 |
const AGENT_ATTR = 'data-extendify-agent-block-id'; |
| 5 |
const AGENT_PART_ID = 'data-extendify-part-block-id'; |
| 6 |
|
| 7 |
export const isAgentAvailable = () => !!window.extAgentData; |
| 8 |
|
| 9 |
export const hasAgentBlockSelected = () => { |
| 10 |
if (!isAgentAvailable()) return false; |
| 11 |
return !!useQuickEditStore.getState().agentBlock; |
| 12 |
}; |
| 13 |
|
| 14 |
// listener(hasBlock: boolean). Returns an unsubscribe fn. |
| 15 |
export const subscribeToAgentBlock = (listener) => { |
| 16 |
if (!isAgentAvailable()) return () => {}; |
| 17 |
let prev = !!useQuickEditStore.getState().agentBlock; |
| 18 |
return useQuickEditStore.subscribe((state) => { |
| 19 |
const next = !!state.agentBlock; |
| 20 |
if (next !== prev) { |
| 21 |
prev = next; |
| 22 |
listener(next); |
| 23 |
} |
| 24 |
}); |
| 25 |
}; |
| 26 |
|
| 27 |
// Synchronous-readable cache of the agent sidebar's open state. The click |
| 28 |
// rule runs in a sync capture-phase listener and can't await a dynamic |
| 29 |
// import to choose between today's commit and the silent-stage bridge. |
| 30 |
// Watcher is kicked off lazily on first read so a Jest resetModules + doMock |
| 31 |
// can swap the agent store before the import resolves. |
| 32 |
let cachedSidebarOpen = false; |
| 33 |
let watcherStarted = false; |
| 34 |
const startSidebarWatcher = () => { |
| 35 |
if (watcherStarted || !isAgentAvailable()) return; |
| 36 |
watcherStarted = true; |
| 37 |
import('@agent/state/global').then(({ useGlobalStore }) => { |
| 38 |
cachedSidebarOpen = !!useGlobalStore.getState().open; |
| 39 |
useGlobalStore.subscribe((state) => { |
| 40 |
cachedSidebarOpen = !!state.open; |
| 41 |
}); |
| 42 |
}); |
| 43 |
}; |
| 44 |
|
| 45 |
export const isAgentSidebarOpen = () => { |
| 46 |
startSidebarWatcher(); |
| 47 |
return cachedSidebarOpen; |
| 48 |
}; |
| 49 |
|
| 50 |
const findAgentTagged = (el) => { |
| 51 |
let node = el; |
| 52 |
while (node && node !== document.body) { |
| 53 |
if (node.hasAttribute?.(AGENT_ATTR) || node.hasAttribute?.(AGENT_PART_ID)) { |
| 54 |
return node; |
| 55 |
} |
| 56 |
node = node.parentElement; |
| 57 |
} |
| 58 |
return null; |
| 59 |
}; |
| 60 |
|
| 61 |
// Bridges the gap between the click and the Agent's own selection |
| 62 |
// outline (which renders after panel mount + ResizeObserver settles). |
| 63 |
const flashSelection = (el) => { |
| 64 |
if (!el) return; |
| 65 |
el.classList.add('extendify-quick-edit-ask-flash'); |
| 66 |
window.setTimeout(() => { |
| 67 |
el.classList.remove('extendify-quick-edit-ask-flash'); |
| 68 |
}, 1500); |
| 69 |
}; |
| 70 |
|
| 71 |
// Focus the agent chat textarea so the user can type their request right |
| 72 |
// away. The textarea is created when the panel mounts; the brief retry |
| 73 |
// covers the open-from-closed case where it isn't in the DOM yet (when the |
| 74 |
// sidebar is already open it's found on the first try). preventScroll keeps |
| 75 |
// the block the user just clicked in view rather than yanking to the chat. |
| 76 |
const focusChatInput = (tries = 20) => { |
| 77 |
const ta = document.querySelector('#extendify-agent-chat-textarea'); |
| 78 |
if (ta) { |
| 79 |
ta.focus({ preventScroll: true }); |
| 80 |
return; |
| 81 |
} |
| 82 |
if (tries > 0) setTimeout(() => focusChatInput(tries - 1), 50); |
| 83 |
}; |
| 84 |
|
| 85 |
// Stage a block for the already-open agent. No setOpen — the sidebar is |
| 86 |
// already up — but we DO focus the chat so the cursor lands ready to type, |
| 87 |
// matching the Ask AI pill and the selector behavior on a fresh open. The |
| 88 |
// visual bridge is the same flash + the agent's DOMHighlighter outline that |
| 89 |
// engages once agentBlock is set. |
| 90 |
export const stageAgentBlock = (el) => { |
| 91 |
if (!isAgentAvailable()) return; |
| 92 |
const match = findAgentTagged(el); |
| 93 |
if (!match) return; |
| 94 |
flashSelection(match); |
| 95 |
const next = buildAgentBlockDescriptor(match); |
| 96 |
const current = useQuickEditStore.getState().agentBlock; |
| 97 |
if (!current || current.id !== next.id || current.target !== next.target) { |
| 98 |
useQuickEditStore.setState({ |
| 99 |
agentBlock: next, |
| 100 |
agentBlockCode: null, |
| 101 |
}); |
| 102 |
} |
| 103 |
focusChatInput(); |
| 104 |
}; |
| 105 |
|
| 106 |
export const askAiAboutElement = async (el) => { |
| 107 |
if (!isAgentAvailable()) return; |
| 108 |
|
| 109 |
const match = findAgentTagged(el); |
| 110 |
flashSelection(match || el); |
| 111 |
|
| 112 |
// setBlock before setOpen so DOMHighlighter sees the block on mount. |
| 113 |
const { useGlobalStore } = await import('@agent/state/global'); |
| 114 |
const { useEditModeStore } = await import('@quick-edit/state/edit-mode'); |
| 115 |
|
| 116 |
if (match) { |
| 117 |
// Edit mode is the gate that mounts DOMHighlighter — turn it on so |
| 118 |
// the X-close indicator renders alongside the selection. Skip |
| 119 |
// re-setting agentBlock when the user is re-clicking Ask AI on |
| 120 |
// the already-staged block: pushing a new descriptor would churn |
| 121 |
// DOMHighlighter and any workflow that pinned the block. |
| 122 |
useEditModeStore.getState().setOn(true); |
| 123 |
const next = buildAgentBlockDescriptor(match); |
| 124 |
const current = useQuickEditStore.getState().agentBlock; |
| 125 |
if (!current || current.id !== next.id || current.target !== next.target) { |
| 126 |
useQuickEditStore.setState({ |
| 127 |
agentBlock: next, |
| 128 |
agentBlockCode: null, |
| 129 |
}); |
| 130 |
} |
| 131 |
} |
| 132 |
useGlobalStore.getState().setOpen(true); |
| 133 |
|
| 134 |
focusChatInput(); |
| 135 |
}; |
| 136 |
|