Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
commit
03634b1ca3
11340 files changed
+3442369
No files matched your search
@@ -0,0 +1,38 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
import { ErrorBoundary } from '@/components/error-boundary'
|
||||
import { ThemeProvider } from '@/themes/context'
|
||||
|
||||
import { PetOverlayApp } from './pet-overlay-app'
|
||||
|
||||
/**
|
||||
* Boot the pet-overlay window. Loaded by the same bundle as the main app but
|
||||
* via `?win=overlay`, so it shares CSS/atoms while mounting a minimal, transparent
|
||||
* surface (no app shell, no gateway, no I18n — the bubble strings are inline).
|
||||
*
|
||||
* The index.html boot script paints an OPAQUE themed background to avoid a flash
|
||||
* in normal windows; the overlay must be see-through, so we force every host
|
||||
* layer transparent with a late, high-specificity style tag.
|
||||
*/
|
||||
export function mountPetOverlay(): void {
|
||||
const style = document.createElement('style')
|
||||
style.textContent = 'html,body,#root{background:transparent !important;}'
|
||||
document.head.appendChild(style)
|
||||
|
||||
const root = document.getElementById('root')
|
||||
|
||||
if (!root) {
|
||||
return
|
||||
}
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary label="pet-overlay">
|
||||
<ThemeProvider>
|
||||
<PetOverlayApp />
|
||||
</ThemeProvider>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { PetHeartField, playVibeHearts } from '@/components/chat/vibe-hearts'
|
||||
import { PetBubble } from '@/components/pet/pet-bubble'
|
||||
import { PetSprite } from '@/components/pet/pet-sprite'
|
||||
import { type PetZoomAnchor, usePetZoomGesture } from '@/components/pet/use-pet-zoom-gesture'
|
||||
import { Mail } from '@/lib/icons'
|
||||
import { $petActivity, $petInfo, setPetInfo } from '@/store/pet'
|
||||
import { overlayWindowSize } from '@/store/pet-overlay'
|
||||
import { setAwaitingResponse, setBusy } from '@/store/session'
|
||||
|
||||
// Fallbacks mirror pet-sprite's defaults; the gateway normally sends real values.
|
||||
const DEFAULT_FRAME_W = 192
|
||||
const DEFAULT_FRAME_H = 208
|
||||
const DEFAULT_SCALE = 0.33
|
||||
|
||||
// Must match the root's paddingBottom — the sprite renders bottom-centered, this
|
||||
// many px above the window's bottom edge. Used to anchor the resize.
|
||||
const PET_PADDING_BOTTOM = 24
|
||||
|
||||
// A sprite pixel counts as "solid" (interactive) at/above this alpha (0-255).
|
||||
// Low enough to catch anti-aliased edges, high enough that the faint halo around
|
||||
// the art still clicks through.
|
||||
const ALPHA_HIT_THRESHOLD = 16
|
||||
|
||||
/**
|
||||
* The pop-out overlay's only view: a transparent, draggable mascot with a mini
|
||||
* composer.
|
||||
*
|
||||
* This runs in a separate, gateway-less BrowserWindow (`?win=overlay`). It is a
|
||||
* pure puppet — the main renderer pushes the live pet state over IPC and we
|
||||
* mirror it into the same atoms the in-window pet reads, so `PetSprite` /
|
||||
* `PetBubble` render identically with zero extra logic.
|
||||
*
|
||||
* The window is a full rectangle but mostly transparent; we toggle OS-level
|
||||
* mouse click-through so only the sprite (or the open composer) is interactive
|
||||
* and the empty margins pass clicks through to whatever is behind.
|
||||
*
|
||||
* Gestures on the pet: drag to move it anywhere on screen (even outside the
|
||||
* app), shift-click to pop it back into the window, single-click to open a small
|
||||
* composer, double-click to toggle the app window (minimize ↔ restore). A mail
|
||||
* icon (shown only when a turn finished while you were away) raises the app on
|
||||
* the most recent thread.
|
||||
*/
|
||||
|
||||
// Below this much pointer travel, a press counts as a click, not a drag.
|
||||
const CLICK_SLOP_PX = 3
|
||||
// A second click within this window is a double-click (raise app) and cancels
|
||||
// the deferred single-click (open composer), so a double never flashes it open.
|
||||
const DOUBLE_CLICK_MS = 250
|
||||
|
||||
interface DragState {
|
||||
startX: number
|
||||
startY: number
|
||||
offX: number
|
||||
offY: number
|
||||
width: number
|
||||
height: number
|
||||
moved: boolean
|
||||
}
|
||||
|
||||
export function PetOverlayApp() {
|
||||
const info = useStore($petInfo)
|
||||
const [composerOpen, setComposerOpen] = useState(false)
|
||||
const [draft, setDraft] = useState('')
|
||||
// Mirrored from the main renderer: a finish landed while you were away.
|
||||
const [unread, setUnread] = useState(false)
|
||||
|
||||
const dragRef = useRef<DragState | null>(null)
|
||||
// Last Alt+wheel anchor, consumed by the resize effect to zoom toward the
|
||||
// cursor; null means a non-wheel scale change (slider) → anchor bottom-center.
|
||||
const zoomAnchorRef = useRef<PetZoomAnchor | null>(null)
|
||||
const petRef = useRef<HTMLDivElement | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
// Last mirrored reaction id — a bump means the main window fired a reaction.
|
||||
const lastReactionRef = useRef<number | null>(null)
|
||||
const ignoreRef = useRef(true)
|
||||
const composerOpenRef = useRef(false)
|
||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
|
||||
const setIgnore = (ignore: boolean) => {
|
||||
if (ignoreRef.current !== ignore) {
|
||||
ignoreRef.current = ignore
|
||||
window.hermesDesktop?.petOverlay?.setIgnoreMouse(ignore)
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror pushed state into the shared atoms so PetSprite/PetBubble just work.
|
||||
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
|
||||
useEffect(() => {
|
||||
const off = window.hermesDesktop?.petOverlay?.onState(payload => {
|
||||
setPetInfo(payload.info)
|
||||
$petActivity.set(payload.activity ?? {})
|
||||
setBusy(Boolean(payload.busy))
|
||||
setAwaitingResponse(Boolean(payload.awaiting))
|
||||
setUnread(Boolean(payload.unread))
|
||||
|
||||
// Play a reaction on a new id (ignore the first sync, which just primes it).
|
||||
const reaction = payload.reaction ?? null
|
||||
|
||||
if (lastReactionRef.current === null) {
|
||||
lastReactionRef.current = reaction?.id ?? 0
|
||||
} else if (reaction && reaction.id > lastReactionRef.current) {
|
||||
lastReactionRef.current = reaction.id
|
||||
|
||||
if (reaction.kind === 'vibe') {
|
||||
playVibeHearts()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Tell the main renderer we're mounted so it pushes the current frame (the
|
||||
// subscribe-time pushes during open() can land before this view exists).
|
||||
window.hermesDesktop?.petOverlay?.control({ type: 'ready' })
|
||||
|
||||
return off
|
||||
}, [])
|
||||
|
||||
// Click-through: make only the *solid* sprite pixels (plus the bubble / mail
|
||||
// button / open composer) interactive — clicks on the transparent rectangle
|
||||
// around the art pass through to whatever's behind. With ignore+forward, the
|
||||
// renderer still receives mousemove so we can re-arm the moment the cursor
|
||||
// returns to a solid pixel.
|
||||
useEffect(() => {
|
||||
setIgnore(true)
|
||||
|
||||
// True when the point sits on a solid sprite pixel or on the pet's other
|
||||
// interactive chrome (bubble, mail button). Over the canvas we sample the
|
||||
// rendered alpha; elsewhere inside the pet (bubble/button) we trust DOM
|
||||
// hit-testing. Anything else is transparent backdrop.
|
||||
const isInteractiveAt = (x: number, y: number): boolean => {
|
||||
const pet = petRef.current
|
||||
const target = document.elementFromPoint(x, y)
|
||||
|
||||
if (!pet || !target || !pet.contains(target)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!(target instanceof HTMLCanvasElement)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const rect = target.getBoundingClientRect()
|
||||
|
||||
if (rect.width === 0 || rect.height === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
const ctx = target.getContext('2d')
|
||||
|
||||
if (!ctx) {
|
||||
return true
|
||||
}
|
||||
|
||||
const px = Math.floor((x - rect.left) * (target.width / rect.width))
|
||||
const py = Math.floor((y - rect.top) * (target.height / rect.height))
|
||||
|
||||
try {
|
||||
return ctx.getImageData(px, py, 1, 1).data[3] >= ALPHA_HIT_THRESHOLD
|
||||
} catch {
|
||||
// Tainted/zero-size read — fail open so the pet stays grabbable.
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
if (dragRef.current || composerOpenRef.current) {
|
||||
setIgnore(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setIgnore(!isInteractiveAt(ev.clientX, ev.clientY))
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', onMove)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMove)
|
||||
clearTimeout(clickTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// The whole window must stay interactive while the composer is open (so the
|
||||
// input keeps focus); focus it on open. The overlay is a non-activating panel
|
||||
// (so it never steals the app's cmd/alt-tab anchor) — flip it focusable while
|
||||
// the composer needs the keyboard, then back to non-activating when it closes.
|
||||
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
|
||||
useEffect(() => {
|
||||
composerOpenRef.current = composerOpen
|
||||
|
||||
window.hermesDesktop?.petOverlay?.setFocusable(composerOpen)
|
||||
|
||||
if (composerOpen) {
|
||||
setIgnore(false)
|
||||
// The OS window has to become key first (setFocusable + focus happen in
|
||||
// the main process), so focus the input on the next frame.
|
||||
requestAnimationFrame(() => inputRef.current?.focus())
|
||||
}
|
||||
}, [composerOpen])
|
||||
|
||||
const onPetPointerDown = (e: React.PointerEvent) => {
|
||||
if (e.button !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
;(e.target as Element).setPointerCapture?.(e.pointerId)
|
||||
dragRef.current = {
|
||||
height: window.outerHeight,
|
||||
moved: false,
|
||||
offX: e.screenX - window.screenX,
|
||||
offY: e.screenY - window.screenY,
|
||||
startX: e.screenX,
|
||||
startY: e.screenY,
|
||||
width: window.outerWidth
|
||||
}
|
||||
}
|
||||
|
||||
const onPetPointerMove = (e: React.PointerEvent) => {
|
||||
const drag = dragRef.current
|
||||
|
||||
if (!drag) {
|
||||
return
|
||||
}
|
||||
|
||||
if (Math.hypot(e.screenX - drag.startX, e.screenY - drag.startY) > CLICK_SLOP_PX) {
|
||||
drag.moved = true
|
||||
}
|
||||
|
||||
window.hermesDesktop?.petOverlay?.setBounds({
|
||||
height: drag.height,
|
||||
width: drag.width,
|
||||
x: e.screenX - drag.offX,
|
||||
y: e.screenY - drag.offY
|
||||
})
|
||||
}
|
||||
|
||||
const onPetPointerUp = (e: React.PointerEvent) => {
|
||||
const drag = dragRef.current
|
||||
dragRef.current = null
|
||||
;(e.target as Element).releasePointerCapture?.(e.pointerId)
|
||||
|
||||
if (!drag) {
|
||||
return
|
||||
}
|
||||
|
||||
if (drag.moved) {
|
||||
// A drag cancels any deferred single-click so the composer can't pop open
|
||||
// after you reposition the pet.
|
||||
clearTimeout(clickTimerRef.current)
|
||||
clickTimerRef.current = undefined
|
||||
|
||||
// Remember the spot on the desktop (screen coords) so the pet reopens here
|
||||
// next time / after a restart.
|
||||
window.hermesDesktop?.petOverlay?.control({
|
||||
bounds: { height: drag.height, width: drag.width, x: e.screenX - drag.offX, y: e.screenY - drag.offY },
|
||||
type: 'bounds'
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Shift-click always pops the pet back in (no double-click ambiguity).
|
||||
if (e.shiftKey) {
|
||||
window.hermesDesktop?.petOverlay?.control({ type: 'pop-in' })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Double-click toggles the app window (minimize ↔ restore); defer the
|
||||
// single-click composer toggle so a double never flashes the composer open.
|
||||
if (clickTimerRef.current) {
|
||||
clearTimeout(clickTimerRef.current)
|
||||
clickTimerRef.current = undefined
|
||||
window.hermesDesktop?.petOverlay?.control({ type: 'toggle-app' })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
clickTimerRef.current = setTimeout(() => {
|
||||
clickTimerRef.current = undefined
|
||||
setComposerOpen(open => !open)
|
||||
}, DOUBLE_CLICK_MS)
|
||||
}
|
||||
|
||||
const send = () => {
|
||||
const text = draft.trim()
|
||||
|
||||
if (text) {
|
||||
window.hermesDesktop?.petOverlay?.control({ text, type: 'submit' })
|
||||
}
|
||||
|
||||
setDraft('')
|
||||
setComposerOpen(false)
|
||||
}
|
||||
|
||||
const openApp = () => {
|
||||
// Hide the icon immediately; the main renderer also clears the source flag.
|
||||
setUnread(false)
|
||||
window.hermesDesktop?.petOverlay?.control({ type: 'open-app' })
|
||||
}
|
||||
|
||||
// Alt+wheel over the popped-out pet resizes it. The overlay has no gateway,
|
||||
// so paint the new scale locally for instant feedback, then ask the main
|
||||
// renderer to persist it (it pushes the reconciled scale back). Stash the
|
||||
// cursor anchor for the resize effect; the window itself is grown to fit there.
|
||||
const onScale = useCallback((next: number, anchor: PetZoomAnchor) => {
|
||||
zoomAnchorRef.current = anchor
|
||||
setPetInfo({ ...$petInfo.get(), scale: next })
|
||||
window.hermesDesktop?.petOverlay?.control({ scale: next, type: 'scale' })
|
||||
}, [])
|
||||
|
||||
usePetZoomGesture(petRef, onScale, Boolean(info.enabled && info.spritesheetBase64))
|
||||
|
||||
// Grow/shrink the OS overlay window to fit the pet at its current scale so the
|
||||
// sprite is never cropped — covers both the wheel gesture here and a scale
|
||||
// changed from the app's settings slider (pushed in as a state update). With a
|
||||
// wheel anchor we zoom toward the cursor (keep the pixel under it fixed);
|
||||
// otherwise we anchor the bottom-center (the pet's feet stay planted). New
|
||||
// bounds are persisted so the pet reopens at the right size.
|
||||
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
|
||||
useEffect(() => {
|
||||
if (!info.enabled || !info.spritesheetBase64) {
|
||||
return
|
||||
}
|
||||
|
||||
const { width, height } = overlayWindowSize(
|
||||
info.frameW ?? DEFAULT_FRAME_W,
|
||||
info.frameH ?? DEFAULT_FRAME_H,
|
||||
info.scale ?? DEFAULT_SCALE
|
||||
)
|
||||
|
||||
const curW = window.outerWidth
|
||||
const curH = window.outerHeight
|
||||
|
||||
if (width === curW && height === curH) {
|
||||
zoomAnchorRef.current = null
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const anchor = zoomAnchorRef.current
|
||||
zoomAnchorRef.current = null
|
||||
|
||||
// The sprite scales about its bottom-center, at window-local (curW/2,
|
||||
// curH - paddingBottom). Hold the anchor pixel fixed on screen as it scales;
|
||||
// with no wheel anchor we pin the bottom-center itself (ratio 1 ⇒ no shift).
|
||||
const ratio = anchor?.ratio ?? 1
|
||||
const ax = anchor?.clientX ?? curW / 2
|
||||
const ay = anchor?.clientY ?? curH - PET_PADDING_BOTTOM
|
||||
|
||||
const bounds = {
|
||||
height,
|
||||
width,
|
||||
x: Math.round(window.screenX + ax - (ax - curW / 2) * ratio - width / 2),
|
||||
y: Math.round(window.screenY + ay - (ay - (curH - PET_PADDING_BOTTOM)) * ratio - (height - PET_PADDING_BOTTOM))
|
||||
}
|
||||
|
||||
window.hermesDesktop?.petOverlay?.setBounds(bounds)
|
||||
window.hermesDesktop?.petOverlay?.control({ bounds, type: 'bounds' })
|
||||
}, [info.enabled, info.spritesheetBase64, info.scale, info.frameW, info.frameH])
|
||||
|
||||
if (!info.enabled || !info.spritesheetBase64) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onPointerDown={e => {
|
||||
// Click on the transparent backdrop (not the pet/composer) dismisses
|
||||
// the composer.
|
||||
if (composerOpen && e.target === e.currentTarget) {
|
||||
setComposerOpen(false)
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
background: 'transparent',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100vh',
|
||||
justifyContent: 'flex-end',
|
||||
paddingBottom: PET_PADDING_BOTTOM,
|
||||
userSelect: 'none',
|
||||
width: '100vw'
|
||||
}}
|
||||
>
|
||||
{composerOpen && (
|
||||
<input
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
send()
|
||||
} else if (e.key === 'Escape') {
|
||||
setComposerOpen(false)
|
||||
}
|
||||
}}
|
||||
placeholder="Message…"
|
||||
ref={inputRef}
|
||||
style={{
|
||||
background: 'var(--ui-bg-elevated)',
|
||||
border: '1px solid var(--ui-stroke-secondary)',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 6px 18px rgba(0,0,0,0.28)',
|
||||
color: 'var(--foreground)',
|
||||
fontSize: 12,
|
||||
marginBottom: 8,
|
||||
outline: 'none',
|
||||
padding: '4px 8px',
|
||||
width: 184
|
||||
}}
|
||||
value={draft}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
onPointerDown={onPetPointerDown}
|
||||
onPointerMove={onPetPointerMove}
|
||||
onPointerUp={onPetPointerUp}
|
||||
ref={petRef}
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
cursor: 'grab',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
touchAction: 'none'
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<PetBubble />
|
||||
</div>
|
||||
<div style={{ lineHeight: 0, position: 'relative' }}>
|
||||
<PetSprite info={info} pauseWhenUnfocused={false} />
|
||||
|
||||
{/* Hearts on the popped-out pet — identical to in-window. */}
|
||||
<PetHeartField
|
||||
petH={(info.frameH ?? DEFAULT_FRAME_H) * (info.scale ?? DEFAULT_SCALE)}
|
||||
petW={(info.frameW ?? DEFAULT_FRAME_W) * (info.scale ?? DEFAULT_SCALE)}
|
||||
/>
|
||||
|
||||
{/* Mail icon: only when a finish landed while you were away. Jumps to
|
||||
the app's most recent thread. Anchored to the sprite (kept inside
|
||||
its box so the overlay's click-through hit-test still catches it);
|
||||
stopPropagation keeps a click from starting a window drag. */}
|
||||
{unread && (
|
||||
<button
|
||||
aria-label="Open in Hermes"
|
||||
onClick={openApp}
|
||||
onPointerDown={e => e.stopPropagation()}
|
||||
onPointerUp={e => e.stopPropagation()}
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
background: 'var(--ui-bg-elevated)',
|
||||
border: '1px solid var(--ui-stroke-secondary)',
|
||||
borderRadius: 999,
|
||||
boxShadow: '0 4px 14px rgba(0,0,0,0.22)',
|
||||
color: 'var(--foreground)',
|
||||
cursor: 'pointer',
|
||||
display: 'inline-flex',
|
||||
height: 24,
|
||||
justifyContent: 'center',
|
||||
padding: 0,
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: 24
|
||||
}}
|
||||
title="Open in Hermes"
|
||||
type="button"
|
||||
>
|
||||
<Mail style={{ height: 13, width: 13 }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user