mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
feat(desktop): two-way reaction UI — tapback pill, frimousse picker, live paint
One slot, Slack-style: on assistant rows the picker trigger and the landed reaction are the same far-right element, so reacting never shifts layout (empty → ☺ following the action bar's hover fade; reacted → the emoji, always visible, always full-strength). User bubbles react via right-click and show the badge beneath, in the checkpoint row's register. - Clicks paint instantly from a local nanostores overlay — no round-trip in the loop; the RPC persists behind it and rolls back visibly on rejection - Agent reactions land via the message.reaction event into an overlay keyed by DURABLE row id, so the end-of-turn resume (which regenerates renderer ids and rebuilds from in-memory history) can't clobber the paint - Full picker is frimousse behind the six-emoji quick row, fed from bundled emojibase-data served at ./emojibase by a small vite plugin (offline, no CDN), with Slack-style alternating cell tints keyed off the codepoint - Reaction picker opts out of the shared popover glass: solid surface so 15%-alpha hover tints stay readable - react_to_message tool blocks are suppressed in the transcript (like todo): the reaction appearing IS the UI; failures still render - rowId reaches rehydrated messages from both transcript shapes (gateway row_id, REST numeric id)
This commit is contained in:
parent
7d92056c49
commit
6ec319f530
23 changed files with 991 additions and 543 deletions
|
|
@ -12,9 +12,9 @@
|
|||
},
|
||||
"scripts": {
|
||||
"clean": "npm run clean:e2e && npm run clean:renderer && npm run clean:electron",
|
||||
"clean:e2e":"tsc --build tsconfig.e2e.json --clean",
|
||||
"clean:renderer":"tsc --build tsconfig.json --clean ",
|
||||
"clean:electron":"tsc --build tsconfig.electron.json --clean",
|
||||
"clean:e2e": "tsc --build tsconfig.e2e.json --clean",
|
||||
"clean:renderer": "tsc --build tsconfig.json --clean ",
|
||||
"clean:electron": "tsc --build tsconfig.electron.json --clean",
|
||||
"dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron\"",
|
||||
"dev:fake-boot": "cross-env HERMES_DESKTOP_BOOT_FAKE=1 HERMES_DESKTOP_BOOT_FAKE_STEP_MS=650 npm run dev",
|
||||
"dev:mock": "node scripts/dev-mock.mjs",
|
||||
|
|
@ -101,7 +101,9 @@
|
|||
"d3-force": "^3.0.0",
|
||||
"dnd-core": "^14.0.1",
|
||||
"dompurify": "^3.4.11",
|
||||
"emojibase-data": "^16.0.3",
|
||||
"fflate": "^0.8.3",
|
||||
"frimousse": "^0.3.0",
|
||||
"hast-util-from-html-isomorphic": "^2.0.0",
|
||||
"hast-util-to-text": "^4.0.2",
|
||||
"ignore": "^7.0.5",
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import { flashPetActivity, markPetUnread, setPetActivity } from '@/store/pet'
|
|||
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
import { followActiveSessionCwd } from '@/store/projects'
|
||||
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
|
||||
import { recordAgentReaction } from '@/store/reactions-local'
|
||||
import {
|
||||
$currentCwd,
|
||||
$currentModel,
|
||||
|
|
@ -53,6 +54,7 @@ import {
|
|||
setCurrentReasoningEffort,
|
||||
setCurrentServiceTier,
|
||||
setCurrentUsage,
|
||||
setMessages,
|
||||
setSessions,
|
||||
setTurnStartedAt,
|
||||
setYoloActive
|
||||
|
|
@ -994,6 +996,53 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
|||
if (isActiveEvent) {
|
||||
revealDesktopPane(payload?.pane ?? '')
|
||||
}
|
||||
} else if (event.type === 'message.reaction') {
|
||||
// The agent reacted to a message via the desktop-gated
|
||||
// react_to_message tool. Already persisted — this only paints it now
|
||||
// instead of at the next resume. Fresh ChatMessage object per change:
|
||||
// the runtime repository caches normalized ThreadMessages in a WeakMap
|
||||
// keyed by ChatMessage identity.
|
||||
const reactedRowId = payload?.row_id
|
||||
|
||||
if (typeof reactedRowId === 'number') {
|
||||
const nextReactions = Array.isArray(payload?.reactions) ? payload.reactions : []
|
||||
const reactedRole = payload?.role === 'assistant' ? 'assistant' : 'user'
|
||||
|
||||
setMessages(messages => {
|
||||
// Preferred leg: the message already knows its durable row id
|
||||
// (rehydrated transcript, or a live row that has round-tripped).
|
||||
const byRowId = messages.find(message => message.rowId === reactedRowId)
|
||||
|
||||
if (byRowId) {
|
||||
// Overlay survives the end-of-turn resume, which rebuilds from
|
||||
// in-memory history that doesn't carry this mid-turn DB write.
|
||||
recordAgentReaction(reactedRowId, nextReactions)
|
||||
|
||||
return messages.map(message =>
|
||||
message.rowId === reactedRowId ? { ...message, reactions: nextReactions } : message
|
||||
)
|
||||
}
|
||||
|
||||
// Live leg: the targeted message is still optimistic (no rowId —
|
||||
// it hasn't round-tripped through a resume). The agent's default
|
||||
// target is the newest message of that role, so stamp the reaction
|
||||
// AND the now-known row id onto it. Without this the event matches
|
||||
// nothing and the reaction only appears after a reload.
|
||||
const lastIndex = messages.findLastIndex(
|
||||
message => message.role === reactedRole && message.rowId === undefined
|
||||
)
|
||||
|
||||
if (lastIndex === -1) {
|
||||
return messages
|
||||
}
|
||||
|
||||
recordAgentReaction(reactedRowId, nextReactions)
|
||||
|
||||
return messages.map((message, index) =>
|
||||
index === lastIndex ? { ...message, rowId: reactedRowId, reactions: nextReactions } : message
|
||||
)
|
||||
})
|
||||
}
|
||||
} else if (event.type === 'status.update') {
|
||||
if (sessionId && payload?.kind === 'compacting') {
|
||||
setSessionCompacting(sessionId, true)
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ function preserveStructuralParts(message: ChatMessage, previous: ChatMessage): C
|
|||
// or reference identity the runtime already guarantees.
|
||||
// timestamp — presentation-only (sort/age display), never affects transcript equality
|
||||
// attachmentRefs — composer-side metadata; already reconciled in reconcileResumeMessages
|
||||
// rowId — durable backend identity; stable for a given row, never changes what's painted
|
||||
//
|
||||
// If your new field affects what the user sees in the transcript, add it to
|
||||
// COMPARED. If it's metadata that shouldn't trigger a re-render, add it to
|
||||
|
|
@ -99,8 +100,17 @@ const _chatMessageFieldsExhaustive: {
|
|||
[K in Exclude<keyof ChatMessage, (typeof COMPARED_FIELDS)[number] | (typeof IGNORED_FIELDS)[number]>]: never
|
||||
} = {}
|
||||
|
||||
const COMPARED_FIELDS = ['id', 'role', 'pending', 'error', 'hidden', 'branchGroupId', 'interim'] as const
|
||||
const IGNORED_FIELDS = ['timestamp', 'attachmentRefs', 'parts'] as const
|
||||
const COMPARED_FIELDS = [
|
||||
'id',
|
||||
'role',
|
||||
'pending',
|
||||
'error',
|
||||
'hidden',
|
||||
'branchGroupId',
|
||||
'interim',
|
||||
'reactions'
|
||||
] as const
|
||||
const IGNORED_FIELDS = ['timestamp', 'attachmentRefs', 'parts', 'rowId'] as const
|
||||
|
||||
// Compile-time check: every ChatMessagePart discriminant must be handled by
|
||||
// chatPartsEquivalent. If @assistant-ui adds a new part type, this fails tsc.
|
||||
|
|
@ -173,6 +183,23 @@ export function chatPartsEquivalent(aPart: ChatMessage['parts'][number], bPart:
|
|||
return aKeys.every(k => aPrimitive[k] === bPrimitive[k])
|
||||
}
|
||||
|
||||
export function chatReactionsEquivalent(a: ChatMessage['reactions'], b: ChatMessage['reactions']): boolean {
|
||||
const aList = a ?? []
|
||||
const bList = b ?? []
|
||||
|
||||
if (aList === bList) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
aList.length === bList.length &&
|
||||
aList.every(
|
||||
(reaction, index) =>
|
||||
reaction.emoji === bList[index].emoji && reaction.author === bList[index].author
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function chatMessagesEquivalent(a: ChatMessage, b: ChatMessage): boolean {
|
||||
if (
|
||||
a.id !== b.id ||
|
||||
|
|
@ -183,7 +210,8 @@ export function chatMessagesEquivalent(a: ChatMessage, b: ChatMessage): boolean
|
|||
a.branchGroupId !== b.branchGroupId ||
|
||||
// Interim gates the action footer, so flipping it must repaint (e.g. a
|
||||
// previewed final settling onto a sealed interim bubble restores the bar).
|
||||
(a.interim ?? false) !== (b.interim ?? false)
|
||||
(a.interim ?? false) !== (b.interim ?? false) ||
|
||||
!chatReactionsEquivalent(a.reactions, b.reactions)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
|
@ -260,6 +288,19 @@ export function reconcileResumeMessages(nextMessages: ChatMessage[], previousMes
|
|||
preserved = { ...preserved, attachmentRefs: [...previous.attachmentRefs] }
|
||||
}
|
||||
|
||||
// Reactions and the row id come from the same authoritative rows as the
|
||||
// text, but a live/optimistic row that hasn't round-tripped yet carries
|
||||
// neither. Carry the cached copy forward so a reaction doesn't blink off
|
||||
// mid-turn. NEW object every time — the runtime repository's WeakMap
|
||||
// caches normalized ThreadMessages by ChatMessage identity.
|
||||
if (sameTurn && preserved.rowId === undefined && previous.rowId !== undefined) {
|
||||
preserved = { ...preserved, rowId: previous.rowId }
|
||||
}
|
||||
|
||||
if (sameTurn && preserved.reactions === undefined && previous.reactions?.length) {
|
||||
preserved = { ...preserved, reactions: [...previous.reactions] }
|
||||
}
|
||||
|
||||
const previousImages = embeddedImageUrls(previousText)
|
||||
|
||||
if (!previousImages.length || embeddedImageUrls(chatMessageText(preserved)).length) {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
useMessageRuntime
|
||||
} from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type FC, useCallback, useMemo } from 'react'
|
||||
import { type FC, useCallback, useMemo, useState } from 'react'
|
||||
|
||||
import {
|
||||
contentHasVisibleText,
|
||||
|
|
@ -15,6 +15,7 @@ import {
|
|||
pickPrimaryPreviewTarget
|
||||
} from '@/components/assistant-ui/thread/content'
|
||||
import { MESSAGE_PARTS_COMPONENTS } from '@/components/assistant-ui/thread/message-parts'
|
||||
import { ReactionPicker } from '@/components/assistant-ui/thread/message-reactions'
|
||||
import { ResponseLoadingIndicator, StreamStallIndicator } from '@/components/assistant-ui/thread/status'
|
||||
import { formatMessageTimestamp } from '@/components/assistant-ui/thread/timestamp'
|
||||
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
|
||||
|
|
@ -22,15 +23,22 @@ import { PreviewAttachment } from '@/components/chat/preview-attachment'
|
|||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { useI18n } from '@/i18n'
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { AudioLines, GitForkIcon, Loader2Icon, RefreshCwIcon, VolumeXIcon, XIcon } from '@/lib/icons'
|
||||
import { AudioLines, GitForkIcon, Loader2Icon, RefreshCwIcon, SmilePlusIcon, VolumeXIcon, XIcon } from '@/lib/icons'
|
||||
import { extractPreviewTargets } from '@/lib/preview-targets'
|
||||
import { formatAgo } from '@/lib/time'
|
||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { toggleMessageReaction } from '@/store/reactions'
|
||||
import { $agentReactions, $localReactions, mergeReactions, setLocalReaction } from '@/store/reactions-local'
|
||||
import { $voicePlayback } from '@/store/voice-playback'
|
||||
import type { MessageReaction } from '@/types/hermes'
|
||||
|
||||
// Stable empty identity — a fresh [] per render would re-run every consumer.
|
||||
const EMPTY_REACTIONS: MessageReaction[] = []
|
||||
|
||||
interface MessageActionProps {
|
||||
messageId: string
|
||||
|
|
@ -135,8 +143,41 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, getMessageText,
|
|||
const { t } = useI18n()
|
||||
const copy = t.assistant.thread
|
||||
|
||||
const reactions = useAuiState(s => {
|
||||
const custom = (s.message.metadata?.custom ?? {}) as { reactions?: MessageReaction[] }
|
||||
|
||||
return custom.reactions ?? EMPTY_REACTIONS
|
||||
})
|
||||
|
||||
const rowId = useAuiState(s => {
|
||||
const custom = (s.message.metadata?.custom ?? {}) as { rowId?: number }
|
||||
|
||||
return custom.rowId
|
||||
})
|
||||
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const localAll = useStore($localReactions)
|
||||
const agentLive = useStore($agentReactions)
|
||||
|
||||
const shownReactions = mergeReactions(
|
||||
reactions,
|
||||
localAll[messageId],
|
||||
rowId !== undefined ? agentLive[rowId] : undefined
|
||||
)
|
||||
|
||||
const react = useCallback(
|
||||
(emoji: null | string) => {
|
||||
setPickerOpen(false)
|
||||
// Flip the UI immediately — a tapback is direct manipulation and must
|
||||
// never wait on a round-trip. Persistence follows in the background.
|
||||
setLocalReaction(messageId, emoji)
|
||||
void toggleMessageReaction({ id: messageId, role: 'assistant', rowId, reactions } as ChatMessage, emoji)
|
||||
},
|
||||
[messageId, reactions, rowId]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="relative flex w-full shrink-0 justify-end">
|
||||
<div className="relative flex w-full shrink-0 items-center justify-end gap-1.5">
|
||||
<ActionBarPrimitive.Root
|
||||
className={
|
||||
// NOTE: intentionally NOT `hideWhenRunning`. That prop unmounts the
|
||||
|
|
@ -170,6 +211,40 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, getMessageText,
|
|||
</TooltipIconButton>
|
||||
</ActionBarPrimitive.Reload>
|
||||
</ActionBarPrimitive.Root>
|
||||
{/* ONE slot, Slack-style: the picker trigger and the landed reaction are
|
||||
the same element, so reacting never shifts layout. Empty → ☺, hidden
|
||||
until hover like its action-bar neighbors (state lives in styles.css
|
||||
— the aui_msg-reactions rules outweigh Tailwind utilities here).
|
||||
Reacted → the emoji itself, always visible at full strength, and
|
||||
clicking it reopens the picker to switch or retract. Outside
|
||||
ActionBarPrimitive.Root so a landed reaction doesn't ride the bar's
|
||||
hover opacity. */}
|
||||
<ReactionPicker
|
||||
onOpenChange={setPickerOpen}
|
||||
onSelect={react}
|
||||
open={pickerOpen}
|
||||
selected={shownReactions.find(reaction => reaction.author === 'user')?.emoji}
|
||||
>
|
||||
<TooltipIconButton
|
||||
data-reacted={shownReactions.length > 0 || undefined}
|
||||
data-slot="aui_msg-reactions"
|
||||
data-state={pickerOpen ? 'open' : undefined}
|
||||
onClick={() => setPickerOpen(open => !open)}
|
||||
tooltip={copy.react}
|
||||
>
|
||||
{shownReactions.length > 0 ? (
|
||||
<span className="flex items-center gap-0.5 text-[0.8125rem] leading-none">
|
||||
{shownReactions.map(reaction => (
|
||||
<span className="reaction-pop" key={`${reaction.author}-${reaction.emoji}`}>
|
||||
{reaction.emoji}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
<SmilePlusIcon className="size-3.5" />
|
||||
)}
|
||||
</TooltipIconButton>
|
||||
</ReactionPicker>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,13 @@ const ChainToolFallback: FC<ToolCallMessagePartProps> = props => {
|
|||
return null
|
||||
}
|
||||
|
||||
// A reaction's UI is the emoji landing on the bubble (message.reaction
|
||||
// event) — a "React To Message" tool block next to it would be the agent
|
||||
// narrating its own tapback. Failures still render so they're debuggable.
|
||||
if (props.toolName === 'react_to_message' && !props.isError) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (props.toolName === 'delegate_task') {
|
||||
return <DelegateToolPart {...props} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,211 @@
|
|||
import { EmojiPicker } from 'frimousse'
|
||||
import { type FC, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Plus } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { QUICK_REACTIONS } from '@/store/reactions'
|
||||
import type { MessageReaction } from '@/types/hermes'
|
||||
|
||||
// Served from the app's own origin (vite.config.ts `hermes:emojibase-assets`
|
||||
// plugin bundles emojibase-data): Electron must work offline, and the app
|
||||
// should never phone a CDN to draw a picker.
|
||||
const EMOJIBASE_URL = './emojibase'
|
||||
|
||||
// Slack tints its picker cells in a repeating palette (green, blue, yellow,
|
||||
// pink, brown, purple…) so long scrolls stay scannable. Same trick, in the
|
||||
// app's own accent idiom (bg-emerald-500/15 etc. are existing patterns).
|
||||
// Keyed off the emoji's codepoint — deterministic, and stable under
|
||||
// frimousse's virtualized rows (an index cycle would reshuffle on scroll).
|
||||
const CELL_TINTS = [
|
||||
'hover:bg-emerald-500/15 data-[active]:bg-emerald-500/20',
|
||||
'hover:bg-sky-500/15 data-[active]:bg-sky-500/20',
|
||||
'hover:bg-amber-500/15 data-[active]:bg-amber-500/20',
|
||||
'hover:bg-pink-500/15 data-[active]:bg-pink-500/20',
|
||||
'hover:bg-orange-500/15 data-[active]:bg-orange-500/20',
|
||||
'hover:bg-violet-500/15 data-[active]:bg-violet-500/20'
|
||||
] as const
|
||||
|
||||
const cellTint = (emoji: string) => CELL_TINTS[(emoji.codePointAt(0) ?? 0) % CELL_TINTS.length]
|
||||
|
||||
/** The full emoji picker, revealed behind the quick row's "+". Headless — styled here. */
|
||||
const FullEmojiPicker: FC<{ onSelect: (emoji: string) => void }> = ({ onSelect }) => (
|
||||
<EmojiPicker.Root
|
||||
className="flex h-72 w-76 flex-col"
|
||||
emojibaseUrl={EMOJIBASE_URL}
|
||||
onEmojiSelect={emoji => onSelect(emoji.emoji)}
|
||||
>
|
||||
{/* Borderless, underline-on-focus — the app's SearchField idiom (DESIGN.md),
|
||||
not a boxed search bar. Search matches labels AND emojibase tags
|
||||
("lol" → 😂), which frimousse handles natively. */}
|
||||
<EmojiPicker.Search
|
||||
autoFocus
|
||||
className="mx-1 border-b border-(--ui-stroke-tertiary) bg-transparent px-1 pb-1 text-sm outline-hidden focus:border-(--ui-stroke-secondary)"
|
||||
placeholder="Search…"
|
||||
/>
|
||||
<EmojiPicker.Viewport className="relative flex-1 outline-hidden">
|
||||
<EmojiPicker.Loading className="absolute inset-0 grid place-items-center text-xs text-(--ui-text-tertiary)">
|
||||
Loading emoji…
|
||||
</EmojiPicker.Loading>
|
||||
<EmojiPicker.Empty className="absolute inset-0 grid place-items-center text-xs text-(--ui-text-tertiary)">
|
||||
No emoji found.
|
||||
</EmojiPicker.Empty>
|
||||
<EmojiPicker.List
|
||||
className="select-none pb-1"
|
||||
components={{
|
||||
CategoryHeader: ({ category, ...props }) => (
|
||||
<div
|
||||
className="bg-(--ui-bg-elevated) px-1.5 pt-2 pb-1 text-[0.6875rem] text-(--ui-text-tertiary)"
|
||||
{...props}
|
||||
>
|
||||
{category.label}
|
||||
</div>
|
||||
),
|
||||
Emoji: ({ emoji, ...props }) => (
|
||||
<button
|
||||
className={cn('grid size-8 shrink-0 place-items-center rounded-md text-lg', cellTint(emoji.emoji))}
|
||||
{...props}
|
||||
>
|
||||
{emoji.emoji}
|
||||
</button>
|
||||
),
|
||||
Row: ({ children, ...props }) => (
|
||||
<div className="flex px-1" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</EmojiPicker.Viewport>
|
||||
</EmojiPicker.Root>
|
||||
)
|
||||
|
||||
/**
|
||||
* The reaction picker — six quick emoji, then "+" for the full set.
|
||||
*
|
||||
* Rides the shared Popover, so it inherits the app's menu/popover surface
|
||||
* treatment rather than inventing a floating pill (DESIGN.md: popovers get one
|
||||
* shared shadow + hairline; call sites don't reinvent elevation).
|
||||
*/
|
||||
export const ReactionPicker: FC<{
|
||||
align?: 'end' | 'start'
|
||||
children: React.ReactNode
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSelect: (emoji: string) => void
|
||||
open: boolean
|
||||
selected?: string
|
||||
}> = ({ align = 'end', children, onOpenChange, onSelect, open, selected }) => {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
return (
|
||||
<Popover
|
||||
onOpenChange={next => {
|
||||
onOpenChange(next)
|
||||
|
||||
if (!next) {
|
||||
// Always reopen on the quick row.
|
||||
setExpanded(false)
|
||||
}
|
||||
}}
|
||||
open={open}
|
||||
>
|
||||
<PopoverAnchor asChild>{children}</PopoverAnchor>
|
||||
<PopoverContent
|
||||
align={align}
|
||||
// Opt this one surface out of the shared popover glass: emoji hover
|
||||
// tints at 15% alpha are unreadable over blurred transcript text.
|
||||
// Overriding the local surface var keeps the arrow matched for free.
|
||||
className={cn(
|
||||
'w-auto p-1 [--popover-surface:var(--ui-bg-elevated)]',
|
||||
!expanded && 'flex gap-0.5'
|
||||
)}
|
||||
onCloseAutoFocus={event => event.preventDefault()}
|
||||
side="top"
|
||||
>
|
||||
{expanded ? (
|
||||
<FullEmojiPicker onSelect={onSelect} />
|
||||
) : (
|
||||
<>
|
||||
{QUICK_REACTIONS.map(emoji => (
|
||||
<Button
|
||||
aria-label={emoji}
|
||||
aria-pressed={selected === emoji}
|
||||
className={cn('text-base', selected === emoji && 'bg-(--chrome-action-hover)')}
|
||||
key={emoji}
|
||||
onClick={() => {
|
||||
triggerHaptic('selection')
|
||||
onSelect(emoji)
|
||||
}}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
>
|
||||
{emoji}
|
||||
</Button>
|
||||
))}
|
||||
<Button aria-label="More emoji" onClick={() => setExpanded(true)} size="icon-sm" variant="ghost">
|
||||
<Plus />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The reactions a message carries.
|
||||
*
|
||||
* Flat by design (DESIGN.md: "Flat, not boxed") — no pill, no border, no fill.
|
||||
* It reads as quiet metadata in the same register as the message age and the
|
||||
* checkpoint row it sits beside. Your own reaction is clickable to retract;
|
||||
* the agent's is display-only.
|
||||
*/
|
||||
export const ReactionBadge: FC<{
|
||||
className?: string
|
||||
onRetract?: () => void
|
||||
reactions: MessageReaction[]
|
||||
}> = ({ className, onRetract, reactions }) => {
|
||||
if (!reactions.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn('flex items-center gap-1 text-[0.8125rem] leading-none', className)}
|
||||
data-slot="aui_msg-reactions"
|
||||
>
|
||||
{reactions.map(reaction =>
|
||||
reaction.author === 'user' && onRetract ? (
|
||||
<button
|
||||
aria-label={`Remove ${reaction.emoji} reaction`}
|
||||
className="reaction-pop cursor-pointer leading-none transition-transform hover:scale-110 active:scale-95"
|
||||
key={`${reaction.author}-${reaction.emoji}`}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
triggerHaptic('selection')
|
||||
onRetract()
|
||||
}}
|
||||
onPointerDown={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{reaction.emoji}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className="reaction-pop leading-none"
|
||||
key={`${reaction.author}-${reaction.emoji}`}
|
||||
title="Reacted by Hermes"
|
||||
>
|
||||
{reaction.emoji}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,18 +1,27 @@
|
|||
import { ActionBarPrimitive, BranchPickerPrimitive, MessagePrimitive, useAuiState } from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type FC, type ReactNode, useCallback, useRef, useState } from 'react'
|
||||
|
||||
import { DirectiveContent } from '@/components/assistant-ui/directive-text'
|
||||
import { messageAttachmentRefs, messageContentText } from '@/components/assistant-ui/thread/content'
|
||||
import { ReactionBadge, ReactionPicker } from '@/components/assistant-ui/thread/message-reactions'
|
||||
import { type RestoreMessageTarget } from '@/components/assistant-ui/thread/types'
|
||||
import { UserMessageText } from '@/components/assistant-ui/thread/user-message-text'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
import { useI18n } from '@/i18n'
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { StopFilled } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toggleMessageReaction } from '@/store/reactions'
|
||||
import { $agentReactions, $localReactions, mergeReactions, setLocalReaction } from '@/store/reactions-local'
|
||||
import { notifyThreadEditOpen } from '@/store/thread-scroll'
|
||||
import { isWatchWindow } from '@/store/windows'
|
||||
import type { MessageReaction } from '@/types/hermes'
|
||||
|
||||
// Stable empty identity — a fresh [] per render would re-run every consumer.
|
||||
const EMPTY_REACTIONS: MessageReaction[] = []
|
||||
|
||||
export function StickyHumanMessageContainer({
|
||||
attachments,
|
||||
|
|
@ -144,6 +153,39 @@ export const UserMessage: FC<{
|
|||
return messageAttachmentRefs(custom.attachmentRefs)
|
||||
})
|
||||
|
||||
const reactions = useAuiState(s => {
|
||||
const custom = (s.message.metadata?.custom ?? {}) as { reactions?: MessageReaction[] }
|
||||
|
||||
return custom.reactions ?? EMPTY_REACTIONS
|
||||
})
|
||||
|
||||
const rowId = useAuiState(s => {
|
||||
const custom = (s.message.metadata?.custom ?? {}) as { rowId?: number }
|
||||
|
||||
return custom.rowId
|
||||
})
|
||||
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const localAll = useStore($localReactions)
|
||||
const agentLive = useStore($agentReactions)
|
||||
|
||||
const shownReactions = mergeReactions(
|
||||
reactions,
|
||||
localAll[messageId],
|
||||
rowId !== undefined ? agentLive[rowId] : undefined
|
||||
)
|
||||
|
||||
const react = useCallback(
|
||||
(emoji: null | string) => {
|
||||
setPickerOpen(false)
|
||||
// Flip the UI immediately — a tapback is direct manipulation and must
|
||||
// never wait on a round-trip. Persistence follows in the background.
|
||||
setLocalReaction(messageId, emoji)
|
||||
void toggleMessageReaction({ id: messageId, role: 'user', rowId, reactions } as ChatMessage, emoji)
|
||||
},
|
||||
[messageId, reactions, rowId]
|
||||
)
|
||||
|
||||
// Sticky human bubbles clamp to ~2 lines with a soft fade so a long prompt
|
||||
// doesn't dominate the viewport while the response streams underneath; the
|
||||
// clamp lifts on hover / focus (see styles.css). We measure the *unclamped*
|
||||
|
|
@ -257,84 +299,111 @@ export const UserMessage: FC<{
|
|||
>
|
||||
<ActionBarPrimitive.Root className="relative w-full max-w-full" data-slot="aui_user-bubble-actions">
|
||||
<div className="human-message-with-todos-wrapper flex w-full flex-col gap-0">
|
||||
<div className="relative w-full">
|
||||
{readOnly ? (
|
||||
// Spectator transcript: clicking only toggles the clamp so the
|
||||
// full prompt is readable — never opens an edit composer.
|
||||
<button
|
||||
aria-expanded={bodyClamped ? expanded : undefined}
|
||||
className={cn(bubbleClassName, !bodyClamped && 'cursor-default')}
|
||||
onClick={() => {
|
||||
if (!bodyClamped) {
|
||||
return
|
||||
}
|
||||
|
||||
triggerHaptic('selection')
|
||||
setExpanded(value => !value)
|
||||
}}
|
||||
title={bodyClamped ? (expanded ? t.common.collapse : copy.expandMessage) : undefined}
|
||||
type="button"
|
||||
>
|
||||
{bubbleContent}
|
||||
</button>
|
||||
) : (
|
||||
// Always editable — clicking opens the edit composer even while a
|
||||
// turn streams; sending the edit reverts (interrupt + rewind).
|
||||
<ActionBarPrimitive.Edit asChild>
|
||||
<ReactionPicker
|
||||
onOpenChange={setPickerOpen}
|
||||
onSelect={react}
|
||||
open={pickerOpen}
|
||||
selected={shownReactions.find(reaction => reaction.author === 'user')?.emoji}
|
||||
>
|
||||
<div
|
||||
className="relative w-full"
|
||||
onContextMenu={
|
||||
// Right-click is the desktop stand-in for iOS touch-and-hold.
|
||||
// Only offered once the row is persisted (rowId present).
|
||||
readOnly
|
||||
? undefined
|
||||
: event => {
|
||||
event.preventDefault()
|
||||
setPickerOpen(true)
|
||||
}
|
||||
}
|
||||
>
|
||||
{readOnly ? (
|
||||
// Spectator transcript: clicking only toggles the clamp so the
|
||||
// full prompt is readable — never opens an edit composer.
|
||||
<button
|
||||
aria-label={copy.editMessage}
|
||||
className={bubbleClassName}
|
||||
onClick={() => triggerHaptic('selection')}
|
||||
onPointerDown={() => notifyThreadEditOpen()}
|
||||
title={copy.editMessage}
|
||||
aria-expanded={bodyClamped ? expanded : undefined}
|
||||
className={cn(bubbleClassName, !bodyClamped && 'cursor-default')}
|
||||
onClick={() => {
|
||||
if (!bodyClamped) {
|
||||
return
|
||||
}
|
||||
|
||||
triggerHaptic('selection')
|
||||
setExpanded(value => !value)
|
||||
}}
|
||||
title={bodyClamped ? (expanded ? t.common.collapse : copy.expandMessage) : undefined}
|
||||
type="button"
|
||||
>
|
||||
{bubbleContent}
|
||||
</button>
|
||||
</ActionBarPrimitive.Edit>
|
||||
)}
|
||||
{(showStop || showRestore) && (
|
||||
<div className="pointer-events-none absolute right-2 bottom-2 z-10 flex items-center justify-center opacity-0 transition-opacity group-hover/user-message:opacity-100 group-focus-within/user-message:opacity-100">
|
||||
{showStop ? (
|
||||
) : (
|
||||
// Always editable — clicking opens the edit composer even while a
|
||||
// turn streams; sending the edit reverts (interrupt + rewind).
|
||||
<ActionBarPrimitive.Edit asChild>
|
||||
<button
|
||||
aria-label={copy.stop}
|
||||
className={cn('pointer-events-auto size-5', USER_ACTION_ICON_BUTTON_CLASS)}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void onCancel?.()
|
||||
}}
|
||||
title={copy.stop}
|
||||
aria-label={copy.editMessage}
|
||||
className={bubbleClassName}
|
||||
onClick={() => triggerHaptic('selection')}
|
||||
onPointerDown={() => notifyThreadEditOpen()}
|
||||
title={copy.editMessage}
|
||||
type="button"
|
||||
>
|
||||
{StopGlyph}
|
||||
{bubbleContent}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
aria-label={copy.restoreCheckpoint}
|
||||
className={cn('pointer-events-auto size-6', USER_ACTION_ICON_BUTTON_CLASS)}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
triggerHaptic('selection')
|
||||
onRequestRestoreConfirm?.(messageId, {
|
||||
text: messageText,
|
||||
userOrdinal: runtimeUserOrdinal
|
||||
})
|
||||
}}
|
||||
onPointerDown={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
title={copy.restoreFromHere}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="discard" size="0.875rem" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ActionBarPrimitive.Edit>
|
||||
)}
|
||||
{(showStop || showRestore) && (
|
||||
<div className="pointer-events-none absolute right-2 bottom-2 z-10 flex items-center justify-center opacity-0 transition-opacity group-hover/user-message:opacity-100 group-focus-within/user-message:opacity-100">
|
||||
{showStop ? (
|
||||
<button
|
||||
aria-label={copy.stop}
|
||||
className={cn('pointer-events-auto size-5', USER_ACTION_ICON_BUTTON_CLASS)}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void onCancel?.()
|
||||
}}
|
||||
title={copy.stop}
|
||||
type="button"
|
||||
>
|
||||
{StopGlyph}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
aria-label={copy.restoreCheckpoint}
|
||||
className={cn('pointer-events-auto size-6', USER_ACTION_ICON_BUTTON_CLASS)}
|
||||
onClick={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
triggerHaptic('selection')
|
||||
onRequestRestoreConfirm?.(messageId, {
|
||||
text: messageText,
|
||||
userOrdinal: runtimeUserOrdinal
|
||||
})
|
||||
}}
|
||||
onPointerDown={event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
title={copy.restoreFromHere}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="discard" size="0.875rem" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ReactionPicker>
|
||||
{/* Below the bubble, same register as the assistant action row:
|
||||
same emoji size, same vertical padding, right-aligned to the
|
||||
sent bubble. Overlaying the corner read badly in practice. */}
|
||||
<ReactionBadge
|
||||
className="justify-end gap-1.5 py-1.5 pr-1.5"
|
||||
onRetract={() => react(null)}
|
||||
reactions={shownReactions}
|
||||
/>
|
||||
<BranchPickerPrimitive.Root
|
||||
className={cn(
|
||||
'checkpoint-container flex items-center gap-1 pb-0 pt-1 pl-1.5 text-[0.75rem] leading-none text-(--ui-text-tertiary)',
|
||||
|
|
|
|||
|
|
@ -2284,6 +2284,7 @@ export const ar = defineLocale({
|
|||
refresh: 'تحديث',
|
||||
moreActions: 'إجراءات إضافية',
|
||||
branchNewChat: 'تفريع إلى محادثة جديدة',
|
||||
react: 'تفاعل',
|
||||
dismissError: 'تجاهل الخطأ',
|
||||
readAloudFailed: 'فشلت القراءة بصوت عال',
|
||||
preparingAudio: 'جار تجهيز الصوت',
|
||||
|
|
|
|||
|
|
@ -2703,6 +2703,7 @@ export const en: Translations = {
|
|||
refresh: 'Refresh',
|
||||
moreActions: 'More actions',
|
||||
branchNewChat: 'Branch in new chat',
|
||||
react: 'React',
|
||||
dismissError: 'Dismiss error',
|
||||
readAloudFailed: 'Read aloud failed',
|
||||
preparingAudio: 'Preparing audio...',
|
||||
|
|
|
|||
|
|
@ -2533,6 +2533,7 @@ export const ja = defineLocale({
|
|||
refresh: '更新',
|
||||
moreActions: 'その他のアクション',
|
||||
branchNewChat: '新しいチャットでブランチ',
|
||||
react: 'リアクション',
|
||||
dismissError: 'エラーを閉じる',
|
||||
readAloudFailed: '読み上げに失敗しました',
|
||||
preparingAudio: '音声を準備中...',
|
||||
|
|
|
|||
|
|
@ -2301,6 +2301,7 @@ export interface Translations {
|
|||
refresh: string
|
||||
moreActions: string
|
||||
branchNewChat: string
|
||||
react: string
|
||||
dismissError: string
|
||||
readAloudFailed: string
|
||||
preparingAudio: string
|
||||
|
|
|
|||
|
|
@ -2452,6 +2452,7 @@ export const zhHant = defineLocale({
|
|||
refresh: '重新整理',
|
||||
moreActions: '更多動作',
|
||||
branchNewChat: '在新聊天中分支',
|
||||
react: '回應',
|
||||
dismissError: '关闭错误',
|
||||
readAloudFailed: '朗讀失敗',
|
||||
preparingAudio: '正在準備音訊...',
|
||||
|
|
|
|||
|
|
@ -2879,6 +2879,7 @@ export const zh: Translations = {
|
|||
refresh: '刷新',
|
||||
moreActions: '更多操作',
|
||||
branchNewChat: '在新对话中分支',
|
||||
react: '回应',
|
||||
dismissError: '关闭错误',
|
||||
readAloudFailed: '朗读失败',
|
||||
preparingAudio: '正在准备音频...',
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { dedupeGeneratedImageEchoesInParts } from '@/lib/generated-images'
|
|||
import { mediaDisplayLabel, mediaMarkdownHref } from '@/lib/media'
|
||||
import { normalize } from '@/lib/text'
|
||||
import { parseTodos } from '@/lib/todos'
|
||||
import type { SessionMessage, UsageStats } from '@/types/hermes'
|
||||
import type { MessageReaction, SessionMessage, UsageStats } from '@/types/hermes'
|
||||
|
||||
export type ChatMessagePart = Exclude<ThreadMessageLike['content'], string>[number]
|
||||
|
||||
|
|
@ -25,6 +25,10 @@ export type ChatMessage = {
|
|||
interim?: boolean
|
||||
/** Composer attachment ref strings (`@file:...`, `@image:...`) sent with this user message. */
|
||||
attachmentRefs?: string[]
|
||||
/** Durable backend `messages.id`. Absent until the row is persisted. */
|
||||
rowId?: number
|
||||
/** Emoji reactions on this message — one per author (see MessageReaction). */
|
||||
reactions?: MessageReaction[]
|
||||
}
|
||||
|
||||
export type GatewayEventPayload = {
|
||||
|
|
@ -84,6 +88,12 @@ export type GatewayEventPayload = {
|
|||
kind?: string
|
||||
// pane.reveal (agent focusing a desktop pane via the focus_pane tool)
|
||||
pane?: string
|
||||
// message.reaction (agent reacting via the react_to_message tool) — the
|
||||
// durable messages.id, that row's full reaction list after the write, and
|
||||
// the row's role so a live (not-yet-round-tripped) message can be matched.
|
||||
row_id?: number
|
||||
reactions?: MessageReaction[]
|
||||
role?: string
|
||||
// session.title (live auto-title push) — stored session id + generated title
|
||||
session_id?: string
|
||||
title?: string
|
||||
|
|
@ -334,26 +344,38 @@ function transcriptContent(displayKind: SessionMessage['display_kind'], content:
|
|||
|
||||
// A remote backend older than this app serves display_metadata as raw JSON text,
|
||||
// and `in` throws on a primitive — which used to fail the whole session resume.
|
||||
function timelineTaskCount(metadata: SessionMessage['display_metadata']): number | undefined {
|
||||
function parseDisplayMetadata(metadata: SessionMessage['display_metadata']): null | Record<string, unknown> {
|
||||
let parsed: unknown = metadata
|
||||
|
||||
if (typeof parsed === 'string') {
|
||||
try {
|
||||
parsed = JSON.parse(parsed)
|
||||
} catch {
|
||||
return undefined
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null
|
||||
}
|
||||
|
||||
const count = (parsed as { task_count?: unknown }).task_count
|
||||
function timelineTaskCount(metadata: SessionMessage['display_metadata']): number | undefined {
|
||||
const count = parseDisplayMetadata(metadata)?.task_count
|
||||
|
||||
return typeof count === 'number' ? count : undefined
|
||||
}
|
||||
|
||||
export function messageReactions(metadata: SessionMessage['display_metadata']): MessageReaction[] {
|
||||
const reactions = parseDisplayMetadata(metadata)?.reactions
|
||||
|
||||
if (!Array.isArray(reactions)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return reactions.filter(
|
||||
(r): r is MessageReaction => Boolean(r) && typeof r === 'object' && typeof (r as MessageReaction).emoji === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function timelineDisplayContent(message: SessionMessage, content: string): string {
|
||||
if (message.display_kind === 'model_switch') {
|
||||
return 'model changed'
|
||||
|
|
@ -1044,11 +1066,19 @@ export function toChatMessages(messages: SessionMessage[]): ChatMessage[] {
|
|||
flushPendingTools(index)
|
||||
}
|
||||
|
||||
const reactions = messageReactions(message.display_metadata)
|
||||
// Gateway resume names the durable row id `row_id`; the REST transcript
|
||||
// prefetch ships the same messages.id as a numeric `id`. Either one lets
|
||||
// reactions address this exact row later.
|
||||
const rowId = message.row_id ?? (typeof message.id === 'number' ? message.id : undefined)
|
||||
|
||||
result.push({
|
||||
id: `${message.timestamp || Date.now()}-${index}-${displayRole}`,
|
||||
role: displayRole,
|
||||
parts,
|
||||
timestamp: message.timestamp,
|
||||
...(rowId !== undefined ? { rowId } : {}),
|
||||
...(reactions.length ? { reactions } : {}),
|
||||
...(extractedAttachmentRefs ? { attachmentRefs: extractedAttachmentRefs } : {})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -387,6 +387,13 @@ export function toRuntimeMessage(message: ChatMessage): ThreadMessage {
|
|||
|
||||
const createdAt = messageCreatedAt(message)
|
||||
|
||||
// Reactions and the durable row id ride metadata.custom for every role — the
|
||||
// established channel for per-message extras (attachmentRefs below).
|
||||
const reactionMeta = {
|
||||
...(message.rowId !== undefined ? { rowId: message.rowId } : {}),
|
||||
...(message.reactions?.length ? { reactions: message.reactions } : {})
|
||||
}
|
||||
|
||||
if (role === 'user') {
|
||||
return {
|
||||
id: message.id,
|
||||
|
|
@ -394,7 +401,7 @@ export function toRuntimeMessage(message: ChatMessage): ThreadMessage {
|
|||
content: message.parts.filter((part): part is Extract<ChatMessagePart, { type: 'text' }> => part.type === 'text'),
|
||||
attachments: [],
|
||||
createdAt,
|
||||
metadata: { custom: { attachmentRefs: message.attachmentRefs ?? [] } }
|
||||
metadata: { custom: { attachmentRefs: message.attachmentRefs ?? [], ...reactionMeta } }
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
|
|
@ -426,7 +433,7 @@ export function toRuntimeMessage(message: ChatMessage): ThreadMessage {
|
|||
unstable_data: [],
|
||||
steps: [],
|
||||
// Carries ChatMessage.interim to AssistantMessage's footer gate.
|
||||
custom: message.interim ? { interim: true } : {}
|
||||
custom: { ...(message.interim ? { interim: true } : {}), ...reactionMeta }
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ import {
|
|||
IconSettings as Settings,
|
||||
IconSettings2 as Settings2,
|
||||
IconAdjustmentsHorizontal as SlidersHorizontal,
|
||||
IconMoodPlus as SmilePlusIcon,
|
||||
IconSquare as Square,
|
||||
IconChartDots3 as Starmap,
|
||||
IconSteeringWheel as SteeringWheel,
|
||||
|
|
@ -226,6 +227,7 @@ export {
|
|||
Settings,
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
SmilePlusIcon,
|
||||
Square,
|
||||
Starmap,
|
||||
SteeringWheel,
|
||||
|
|
|
|||
67
apps/desktop/src/store/reactions-local.ts
Normal file
67
apps/desktop/src/store/reactions-local.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { atom } from 'nanostores'
|
||||
|
||||
import { applyReaction } from '@/store/reactions'
|
||||
import type { MessageReaction } from '@/types/hermes'
|
||||
|
||||
/**
|
||||
* Reactions the user has set in THIS window, keyed by renderer message id.
|
||||
*
|
||||
* The UI owns this outright. A tapback is a direct manipulation — it flips the
|
||||
* instant you click it, with no round-trip, no gateway, and no dependency on a
|
||||
* message having been persisted yet. Durable state (and the agent's own
|
||||
* reactions) still arrive through `metadata.custom.reactions`; this layer sits
|
||||
* on top of it so the interaction never waits on the backend to feel alive.
|
||||
*/
|
||||
export const $localReactions = atom<Record<string, MessageReaction[]>>({})
|
||||
|
||||
/**
|
||||
* Agent reactions announced live (`message.reaction` events), keyed by the
|
||||
* DURABLE row id — never the renderer message id, which the end-of-turn
|
||||
* resume regenerates (an overlay keyed on the old id would orphan the instant
|
||||
* the transcript rebuilds; "identity is not incidental", AGENTS.md). The
|
||||
* resume also rebuilds from the gateway's in-memory history, which doesn't
|
||||
* carry a reaction written to the DB mid-turn — this overlay outlives that
|
||||
* clobber and a real reload hydrates the same reaction from disk.
|
||||
*/
|
||||
export const $agentReactions = atom<Record<number, MessageReaction[]>>({})
|
||||
|
||||
/** Record an agent reaction painted from a live gateway event. */
|
||||
export function recordAgentReaction(rowId: number, reactions: MessageReaction[]): void {
|
||||
$agentReactions.set({
|
||||
...$agentReactions.get(),
|
||||
[rowId]: reactions.filter(reaction => reaction.author === 'agent')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the durable reaction list with anything this window knows live.
|
||||
*
|
||||
* The user's slot: local wins (they just clicked it — newer by definition).
|
||||
* The agent's slot: the live-event overlay wins over persisted (a mid-turn
|
||||
* reaction reaches the DB before the in-memory history the next resume
|
||||
* projects from), falling back to what the transcript carried.
|
||||
*/
|
||||
export function mergeReactions(
|
||||
persisted: MessageReaction[] | undefined,
|
||||
local: MessageReaction[] | undefined,
|
||||
agentLive?: MessageReaction[]
|
||||
): MessageReaction[] {
|
||||
const persistedList = persisted ?? []
|
||||
|
||||
const userSide = local
|
||||
? local.filter(reaction => reaction.author === 'user')
|
||||
: persistedList.filter(reaction => reaction.author === 'user')
|
||||
|
||||
const agentSide = agentLive ?? persistedList.filter(reaction => reaction.author === 'agent')
|
||||
|
||||
return [...userSide, ...agentSide]
|
||||
}
|
||||
|
||||
/** Toggle the user's reaction on a message — instant, local, no round-trip. */
|
||||
export function setLocalReaction(messageId: string, emoji: null | string): MessageReaction[] {
|
||||
const next = applyReaction($localReactions.get()[messageId], emoji, 'user')
|
||||
|
||||
$localReactions.set({ ...$localReactions.get(), [messageId]: next })
|
||||
|
||||
return next
|
||||
}
|
||||
59
apps/desktop/src/store/reactions.test.ts
Normal file
59
apps/desktop/src/store/reactions.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { applyReaction, QUICK_REACTIONS } from '@/store/reactions'
|
||||
import type { MessageReaction } from '@/types/hermes'
|
||||
|
||||
const at = 1_700_000_000
|
||||
|
||||
function reaction(emoji: string, author: MessageReaction['author']): MessageReaction {
|
||||
return { emoji, author, at }
|
||||
}
|
||||
|
||||
describe('applyReaction', () => {
|
||||
it('adds a reaction to an empty message', () => {
|
||||
expect(applyReaction(undefined, '❤️', 'user')).toMatchObject([{ emoji: '❤️', author: 'user' }])
|
||||
})
|
||||
|
||||
it('replaces the same author’s existing reaction (one per author)', () => {
|
||||
const next = applyReaction([reaction('❤️', 'user')], '😂', 'user')
|
||||
|
||||
expect(next).toHaveLength(1)
|
||||
expect(next[0].emoji).toBe('😂')
|
||||
})
|
||||
|
||||
it('retracts when the live reaction is re-sent', () => {
|
||||
expect(applyReaction([reaction('👍', 'user')], '👍', 'user')).toEqual([])
|
||||
})
|
||||
|
||||
it('clears on an explicit null', () => {
|
||||
expect(applyReaction([reaction('👍', 'user')], null, 'user')).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps authors independent', () => {
|
||||
const next = applyReaction([reaction('🔥', 'agent')], '❤️', 'user')
|
||||
|
||||
expect(next.map(r => r.author).sort()).toEqual(['agent', 'user'])
|
||||
})
|
||||
|
||||
it('retracting one author leaves the other intact', () => {
|
||||
const next = applyReaction([reaction('🔥', 'agent'), reaction('❤️', 'user')], null, 'user')
|
||||
|
||||
expect(next).toMatchObject([{ emoji: '🔥', author: 'agent' }])
|
||||
})
|
||||
|
||||
it('never mutates the input array', () => {
|
||||
const before = [reaction('❤️', 'user')]
|
||||
const snapshot = [...before]
|
||||
|
||||
applyReaction(before, '😂', 'user')
|
||||
|
||||
expect(before).toEqual(snapshot)
|
||||
})
|
||||
})
|
||||
|
||||
describe('QUICK_REACTIONS', () => {
|
||||
it('is the six iOS Tapback defaults, each distinct', () => {
|
||||
expect(QUICK_REACTIONS).toHaveLength(6)
|
||||
expect(new Set(QUICK_REACTIONS).size).toBe(6)
|
||||
})
|
||||
})
|
||||
93
apps/desktop/src/store/reactions.ts
Normal file
93
apps/desktop/src/store/reactions.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { activeGateway } from '@/store/gateway'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $activeSessionId, $messages, setMessages } from '@/store/session'
|
||||
import type { MessageReaction } from '@/types/hermes'
|
||||
|
||||
/** The six iOS Tapback defaults, in Apple's order. */
|
||||
export const QUICK_REACTIONS = ['❤️', '👍', '👎', '😂', '‼️', '❓'] as const
|
||||
|
||||
interface MessageReactResponse {
|
||||
row_id: number
|
||||
reactions: MessageReaction[]
|
||||
}
|
||||
|
||||
/** Apply the local half of a tapback: one reaction per author, re-tap retracts. */
|
||||
export function applyReaction(
|
||||
reactions: MessageReaction[] | undefined,
|
||||
emoji: null | string,
|
||||
author: MessageReaction['author']
|
||||
): MessageReaction[] {
|
||||
const current = reactions ?? []
|
||||
const previous = current.find(reaction => reaction.author === author)
|
||||
const without = current.filter(reaction => reaction.author !== author)
|
||||
|
||||
if (!emoji || previous?.emoji === emoji) {
|
||||
return without
|
||||
}
|
||||
|
||||
return [...without, { emoji, author, at: Date.now() / 1000 }]
|
||||
}
|
||||
|
||||
function writeReactions(messageId: string, reactions: MessageReaction[], rowId?: number) {
|
||||
// A NEW ChatMessage object per change is load-bearing: the runtime
|
||||
// repository caches normalized ThreadMessages in a WeakMap keyed by
|
||||
// ChatMessage identity, so a mutation in place renders stale.
|
||||
// Keyed by the renderer id, not rowId: a live message has no rowId yet.
|
||||
setMessages(messages =>
|
||||
messages.map(message =>
|
||||
message.id === messageId ? { ...message, reactions, ...(rowId === undefined ? {} : { rowId }) } : message
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle *author*'s reaction on a persisted message.
|
||||
*
|
||||
* Optimistic: paints immediately, then lets the backend's returned list win.
|
||||
* A failed write rolls back to the snapshot (desktop AGENTS.md — "be optimistic,
|
||||
* then honest").
|
||||
*/
|
||||
export async function toggleMessageReaction(
|
||||
message: ChatMessage,
|
||||
emoji: null | string,
|
||||
author: MessageReaction['author'] = 'user'
|
||||
): Promise<void> {
|
||||
// A live message hasn't round-tripped through a resume yet, so it carries no
|
||||
// rowId. Rather than disable the affordance (which made reactions invisible
|
||||
// in any active conversation), let the backend resolve the newest row of
|
||||
// this role — which is exactly the message being reacted to.
|
||||
const rowId = message.rowId
|
||||
const sessionId = $activeSessionId.get()
|
||||
const gateway = activeGateway()
|
||||
|
||||
if (!sessionId || !gateway) {
|
||||
notifyError(
|
||||
new Error(!sessionId ? 'No active session' : 'Gateway not connected'),
|
||||
'Could not react'
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const snapshot = $messages.get().find(m => m.id === message.id)?.reactions
|
||||
|
||||
writeReactions(message.id, applyReaction(snapshot, emoji, author))
|
||||
|
||||
try {
|
||||
const result = await gateway.request<MessageReactResponse>('message.react', {
|
||||
session_id: sessionId,
|
||||
...(rowId === undefined ? { newest_role: message.role } : { row_id: rowId }),
|
||||
emoji,
|
||||
author
|
||||
})
|
||||
|
||||
// Learn the row id from the response so later toggles address it directly.
|
||||
writeReactions(message.id, result?.reactions ?? [], result?.row_id)
|
||||
} catch (err) {
|
||||
// Be optimistic, THEN honest: a rejected write rolls back visibly and says
|
||||
// why, instead of the reaction quietly vanishing (desktop AGENTS.md).
|
||||
writeReactions(message.id, snapshot ?? [])
|
||||
notifyError(err, 'Could not react')
|
||||
}
|
||||
}
|
||||
|
|
@ -1638,18 +1638,21 @@ text-* variant utilities. */ .btn-arc {
|
|||
an open diff is excluded — it keeps the full gap, same as in-flow. */
|
||||
[data-slot='aui_turn-pair']
|
||||
> [data-slot='aui_assistant-message-root']:has(
|
||||
> [data-slot='aui_assistant-message-content']
|
||||
> :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'], [data-slot='aui_stream-stall']):last-child
|
||||
):not(:has([data-file-edit]))
|
||||
> [data-slot='aui_assistant-message-content']
|
||||
> :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'], [data-slot='aui_stream-stall']):last-child
|
||||
):not(:has([data-file-edit]))
|
||||
+ [data-slot='aui_assistant-message-root']:has(
|
||||
> [data-slot='aui_assistant-message-content']
|
||||
> :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'], [data-slot='aui_stream-stall']):first-child
|
||||
):not(:has([data-file-edit])) {
|
||||
> [data-slot='aui_assistant-message-content']
|
||||
> :is([data-slot='tool-block'], [data-slot='aui_thinking-disclosure'], [data-slot='aui_stream-stall']):first-child
|
||||
):not(:has([data-file-edit])) {
|
||||
margin-top: calc(var(--scaffold-block-gap) - var(--conversation-turn-gap));
|
||||
}
|
||||
|
||||
/* Message action bars — flat icon hits with default dim; only the hovered/focused control is full-strength. */
|
||||
[data-slot='aui_msg-actions'] button {
|
||||
/* Message action bars — flat icon hits with default dim; only the hovered/focused control is full-strength.
|
||||
The reaction slot lives OUTSIDE the bar (a landed emoji must not ride the
|
||||
bar's hover fade) but is still one of these controls visually. */
|
||||
[data-slot='aui_msg-actions'] button,
|
||||
button[data-slot='aui_msg-reactions'] {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
|
|
@ -1666,25 +1669,65 @@ text-* variant utilities. */ .btn-arc {
|
|||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* A landed reaction is content, not a dimmed affordance — emoji render at
|
||||
full strength everywhere, always (color dim would gray them anyway; they're
|
||||
glyphs, not icons). */
|
||||
button[data-slot='aui_msg-reactions'][data-reacted] {
|
||||
opacity: 1;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* The EMPTY slot is an affordance and follows its action-bar neighbors
|
||||
exactly: hidden until the message is hovered, then the same 0.5 dim, full
|
||||
strength under the pointer. Stylesheet-owned because the base rule above
|
||||
outweighs Tailwind's opacity utilities (0,1,1 vs 0,1,0). */
|
||||
button[data-slot='aui_msg-reactions']:not([data-reacted]) {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.group:hover button[data-slot='aui_msg-reactions']:not([data-reacted]),
|
||||
button[data-slot='aui_msg-reactions']:not([data-reacted]):focus-visible,
|
||||
button[data-slot='aui_msg-reactions']:not([data-reacted])[data-state='open'] {
|
||||
opacity: 0.5;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.group:hover button[data-slot='aui_msg-reactions']:not([data-reacted]):hover,
|
||||
button[data-slot='aui_msg-reactions']:not([data-reacted])[data-state='open'] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Emoji in ANY reaction surface (badge under a user bubble, footer slot,
|
||||
picker rows) never inherit a translucent treatment from their container. */
|
||||
[data-slot='aui_msg-reactions'] .reaction-pop,
|
||||
span[data-slot='aui_msg-reactions'] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-slot='aui_msg-actions'] button:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
[data-slot='aui_msg-actions'] button:hover {
|
||||
[data-slot='aui_msg-actions'] button:hover,
|
||||
button[data-slot='aui_msg-reactions']:hover {
|
||||
background: transparent;
|
||||
color: var(--color-foreground);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-slot='aui_msg-actions'] button:active {
|
||||
[data-slot='aui_msg-actions'] button:active,
|
||||
button[data-slot='aui_msg-reactions']:active {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
[data-slot='aui_msg-actions'] button:focus-visible {
|
||||
[data-slot='aui_msg-actions'] button:focus-visible,
|
||||
button[data-slot='aui_msg-reactions']:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[data-slot='aui_msg-actions'] button svg {
|
||||
[data-slot='aui_msg-actions'] button svg,
|
||||
button[data-slot='aui_msg-reactions'] svg {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
}
|
||||
|
|
@ -2014,6 +2057,26 @@ text-* variant utilities. */ .btn-arc {
|
|||
}
|
||||
}
|
||||
|
||||
@keyframes reaction-pop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.4);
|
||||
}
|
||||
60% {
|
||||
opacity: 1;
|
||||
transform: scale(1.18);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Landing a reaction should feel like something — same pop shape as
|
||||
pet-reveal-pop, scaled down for an inline glyph. */
|
||||
.reaction-pop {
|
||||
animation: reaction-pop 260ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pet-egg,
|
||||
.pet-egg__glow,
|
||||
|
|
@ -2021,6 +2084,9 @@ text-* variant utilities. */ .btn-arc {
|
|||
.pet-reveal {
|
||||
animation: none;
|
||||
}
|
||||
.reaction-pop {
|
||||
animation: none;
|
||||
}
|
||||
.pet-reveal {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
|
|
|
|||
|
|
@ -524,6 +524,15 @@ export type TimelineDisplayMetadata =
|
|||
failed_count?: number
|
||||
duration_seconds?: number
|
||||
}
|
||||
| { reactions: MessageReaction[] }
|
||||
|
||||
/** One emoji reaction on a message. One per author, iOS-Tapback style. */
|
||||
export interface MessageReaction {
|
||||
emoji: string
|
||||
author: 'agent' | 'user'
|
||||
/** Epoch seconds. */
|
||||
at: number
|
||||
}
|
||||
|
||||
export interface SessionMessage {
|
||||
codex_reasoning_items?: unknown
|
||||
|
|
@ -540,6 +549,17 @@ export interface SessionMessage {
|
|||
*/
|
||||
display_metadata?: string | TimelineDisplayMetadata
|
||||
role: 'assistant' | 'system' | 'tool' | 'user'
|
||||
/**
|
||||
* Durable `messages.id` from the backend. The renderer's own message ids are
|
||||
* ephemeral (derived from timestamp+index, and a different shape for live vs
|
||||
* rehydrated vs optimistic rows), so anything addressing a specific persisted
|
||||
* message — reactions — keys off this. Absent on a backend older than this app.
|
||||
*
|
||||
* The gateway resume path names it `row_id`; the REST transcript path
|
||||
* (`SELECT *`) ships the same value as a numeric `id`. Read both.
|
||||
*/
|
||||
row_id?: number
|
||||
id?: number
|
||||
text?: unknown
|
||||
timestamp?: number
|
||||
tool_call_id?: null | string
|
||||
|
|
|
|||
|
|
@ -36,9 +36,47 @@ const debugEntry = (command: string, env: Record<string, string>) =>
|
|||
? path.resolve(__dirname, './src/debug/dev-only.ts')
|
||||
: path.resolve(__dirname, './src/debug/dev-only.noop.ts')
|
||||
|
||||
// The emoji picker (frimousse) fetches `<emojibaseUrl>/<locale>/data.json` at
|
||||
// runtime. Its default is a CDN; Electron must work offline, so serve the
|
||||
// bundled emojibase-data package at a stable local path instead — middleware
|
||||
// in dev, emitted assets in the build. Only the files a locale actually needs.
|
||||
const emojibaseDir =
|
||||
real(path.resolve(__dirname, 'node_modules/emojibase-data')) ??
|
||||
real(path.resolve(__dirname, '../../node_modules/emojibase-data'))
|
||||
|
||||
const EMOJIBASE_PATH = /^[a-z-]+\/(data|messages|shortcodes\/emojibase)\.json$/
|
||||
|
||||
const emojibaseAssets = () => ({
|
||||
name: 'hermes:emojibase-assets',
|
||||
configureServer(server: {
|
||||
middlewares: { use: (route: string, handler: (req: any, res: any, next: () => void) => void) => void }
|
||||
}) {
|
||||
server.middlewares.use('/emojibase', (req, res, next) => {
|
||||
const rel = (req.url ?? '').split('?')[0].replace(/^\/+/, '')
|
||||
if (!emojibaseDir || !EMOJIBASE_PATH.test(rel)) return next()
|
||||
fs.readFile(path.join(emojibaseDir, rel), (err: unknown, buf: Buffer) => {
|
||||
if (err) return next()
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
|
||||
res.end(buf)
|
||||
})
|
||||
})
|
||||
},
|
||||
generateBundle(this: { emitFile: (asset: { type: 'asset'; fileName: string; source: Uint8Array }) => void }) {
|
||||
if (!emojibaseDir) return
|
||||
for (const rel of ['en/data.json', 'en/messages.json', 'en/shortcodes/emojibase.json']) {
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
fileName: `emojibase/${rel}`,
|
||||
source: fs.readFileSync(path.join(emojibaseDir, rel))
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export default defineConfig(({ command }) => ({
|
||||
base: './',
|
||||
plugins: [react(), tailwindcss()],
|
||||
plugins: [react(), tailwindcss(), emojibaseAssets()],
|
||||
css: {
|
||||
// Pin an explicit (empty) PostCSS config. Tailwind is handled entirely by
|
||||
// `@tailwindcss/vite`, so the renderer needs no PostCSS plugins — and
|
||||
|
|
|
|||
492
package-lock.json
generated
492
package-lock.json
generated
|
|
@ -120,7 +120,9 @@
|
|||
"d3-force": "^3.0.0",
|
||||
"dnd-core": "^14.0.1",
|
||||
"dompurify": "^3.4.11",
|
||||
"emojibase-data": "^16.0.3",
|
||||
"fflate": "^0.8.3",
|
||||
"frimousse": "^0.3.0",
|
||||
"hast-util-from-html-isomorphic": "^2.0.0",
|
||||
"hast-util-to-text": "^4.0.2",
|
||||
"ignore": "^7.0.5",
|
||||
|
|
@ -1927,448 +1929,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
"version": "4.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
||||
|
|
@ -10079,6 +9639,33 @@
|
|||
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/emojibase": {
|
||||
"version": "17.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emojibase/-/emojibase-17.0.0.tgz",
|
||||
"integrity": "sha512-bXdpf4HPY3p41zK5swVKZdC/VynsMZ4LoLxdYDE+GucqkFwzcM1GVc4ODfYAlwoKaf2U2oNNUoOO78N96ovpBA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.12.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "ko-fi",
|
||||
"url": "https://ko-fi.com/milesjohnson"
|
||||
}
|
||||
},
|
||||
"node_modules/emojibase-data": {
|
||||
"version": "16.0.3",
|
||||
"resolved": "https://registry.npmjs.org/emojibase-data/-/emojibase-data-16.0.3.tgz",
|
||||
"integrity": "sha512-MopInVCDZeXvqBMPJxnvYUyKw9ImJZqIDr2sABo6acVSPev5IDYX+mf+0tsu96JJyc3INNvgIf06Eso7bdTX2Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "ko-fi",
|
||||
"url": "https://ko-fi.com/milesjohnson"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"emojibase": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
|
|
@ -11197,6 +10784,25 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/frimousse": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/frimousse/-/frimousse-0.3.0.tgz",
|
||||
"integrity": "sha512-kO6LMoKY/cLAYEhXXtqLRaLIE6L/DagpFPrUZaLv3LsUa1/8Iza3HhwZcgN8eZ+weXnhv69eoclNUPohcCa/IQ==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
".",
|
||||
"site"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19",
|
||||
"typescript": ">=5.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
|
||||
|
|
@ -18369,7 +17975,7 @@
|
|||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue