mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 04:08:28 +08:00
Compare commits
4 Commits
fix/requir
...
bb/desktop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12a2d5f435 | ||
|
|
221d6eb63e | ||
|
|
07a1b15974 | ||
|
|
03c9bbfb54 |
@@ -221,6 +221,7 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
||||
onDeleteSession: (sessionId: string) => void
|
||||
onArchiveSession: (sessionId: string) => void
|
||||
onNewSessionInWorkspace: (path: null | string) => void
|
||||
onSendToAll: (text: string) => Promise<number> | void
|
||||
}
|
||||
|
||||
export function ChatSidebar({
|
||||
@@ -231,7 +232,8 @@ export function ChatSidebar({
|
||||
onResumeSession,
|
||||
onDeleteSession,
|
||||
onArchiveSession,
|
||||
onNewSessionInWorkspace
|
||||
onNewSessionInWorkspace,
|
||||
onSendToAll
|
||||
}: ChatSidebarProps) {
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const panesFlipped = useStore($panesFlipped)
|
||||
@@ -746,7 +748,7 @@ export function ChatSidebar({
|
||||
|
||||
{sidebarOpen && (
|
||||
<div className="shrink-0 px-0.5 pb-1 pt-0.5">
|
||||
<ProfileRail />
|
||||
<ProfileRail onSendToAll={onSendToAll} />
|
||||
</div>
|
||||
)}
|
||||
</SidebarContent>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useNavigate } from 'react-router-dom'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||
import { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
@@ -52,6 +53,8 @@ import { DeleteProfileDialog } from '../../profiles/delete-profile-dialog'
|
||||
import { RenameProfileDialog } from '../../profiles/rename-profile-dialog'
|
||||
import { PROFILES_ROUTE } from '../../routes'
|
||||
|
||||
import { SendToAllDialog } from './send-to-all-dialog'
|
||||
|
||||
const RAIL_GAP = 4 // px — matches gap-1 between squares.
|
||||
|
||||
// easeOutBack — a little overshoot so squares spring into their new slot rather
|
||||
@@ -83,7 +86,11 @@ const stepThroughCells: Modifier = ({ containerNodeRect, draggingNodeRect, trans
|
||||
// The active profile pops in its own color — the "where am I" cue. Single-
|
||||
// profile users see only the "+" (create their first profile); everything else
|
||||
// appears once a second profile exists.
|
||||
export function ProfileRail() {
|
||||
interface ProfileRailProps {
|
||||
onSendToAll: (text: string) => Promise<number> | void
|
||||
}
|
||||
|
||||
export function ProfileRail({ onSendToAll }: ProfileRailProps) {
|
||||
const profiles = useStore($profiles)
|
||||
const scope = useStore($profileScope)
|
||||
const gatewayProfile = useStore($activeGatewayProfile)
|
||||
@@ -92,6 +99,7 @@ export function ProfileRail() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [sendAllOpen, setSendAllOpen] = useState(false)
|
||||
const [pendingRename, setPendingRename] = useState<null | ProfileInfo>(null)
|
||||
const [pendingDelete, setPendingDelete] = useState<null | ProfileInfo>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
@@ -245,8 +253,37 @@ export function ProfileRail() {
|
||||
</Tip>
|
||||
</div>
|
||||
|
||||
{/* The "…" overflow: broadcast a prompt to every profile, or jump to the
|
||||
profiles settings page (its old single-tap destination). */}
|
||||
{multiProfile && (
|
||||
<ProfilePill active={false} glyph="ellipsis" label="Manage profiles…" onSelect={() => navigate(PROFILES_ROUTE)} />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label="More profile actions"
|
||||
className="bg-transparent text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground"
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="ellipsis" size="0.875rem" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-44"
|
||||
collisionPadding={{ bottom: 44, left: 8, right: 8, top: 8 }}
|
||||
sideOffset={6}
|
||||
>
|
||||
<DropdownMenuItem onSelect={() => setSendAllOpen(true)}>
|
||||
<Codicon name="send" size="0.875rem" />
|
||||
<span>Send to all</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => navigate(PROFILES_ROUTE)}>
|
||||
<Codicon name="account" size="0.875rem" />
|
||||
<span>Manage</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{/* Land in the new profile on a fresh chat (selectProfile triggers the
|
||||
@@ -273,6 +310,8 @@ export function ProfileRail() {
|
||||
open={pendingDelete !== null}
|
||||
profile={pendingDelete}
|
||||
/>
|
||||
|
||||
<SendToAllDialog onOpenChange={setSendAllOpen} onSend={onSendToAll} open={sendAllOpen} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
85
apps/desktop/src/app/chat/sidebar/send-to-all-dialog.tsx
Normal file
85
apps/desktop/src/app/chat/sidebar/send-to-all-dialog.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { setShowAllProfiles } from '@/store/profile'
|
||||
|
||||
interface SendToAllDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSend: (text: string) => Promise<number> | void
|
||||
}
|
||||
|
||||
// A bare composer that fans one message out to every profile at once. Closes
|
||||
// itself the moment the broadcast is dispatched — the turns run in the
|
||||
// background, so there's no point holding the user on N backends.
|
||||
export function SendToAllDialog({ onOpenChange, onSend, open }: SendToAllDialogProps) {
|
||||
const [text, setText] = useState('')
|
||||
|
||||
const close = (next: boolean) => {
|
||||
if (!next) {
|
||||
setText('')
|
||||
}
|
||||
|
||||
onOpenChange(next)
|
||||
}
|
||||
|
||||
// Fire-and-forget: booting cold backends serially can take a few seconds, so
|
||||
// dispatch and close immediately — progress arrives as toasts. Flip to the
|
||||
// all-profiles view so the broadcast's sessions are visible as they land.
|
||||
const send = () => {
|
||||
const body = text.trim()
|
||||
|
||||
if (!body) {
|
||||
return
|
||||
}
|
||||
|
||||
triggerHaptic('success')
|
||||
setShowAllProfiles(true)
|
||||
void onSend(body)
|
||||
close(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={close} open={open}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Send to all profiles</DialogTitle>
|
||||
<DialogDescription>Opens a fresh session in every profile and sends this message to each. ⌘↵ to send.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Textarea
|
||||
autoFocus
|
||||
onChange={event => setText(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault()
|
||||
send()
|
||||
} else if (event.key === 'Escape') {
|
||||
close(false)
|
||||
}
|
||||
}}
|
||||
placeholder="Message every profile…"
|
||||
rows={4}
|
||||
value={text}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => close(false)} type="button" variant="ghost">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={!text.trim()} onClick={send} type="button">
|
||||
Send to all
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -439,6 +439,7 @@ export function DesktopController() {
|
||||
removeSession,
|
||||
resumeSession,
|
||||
selectSidebarItem,
|
||||
sendToAllProfiles,
|
||||
startFreshSessionDraft
|
||||
} = useSessionActions({
|
||||
activeSessionId,
|
||||
@@ -629,6 +630,7 @@ export function DesktopController() {
|
||||
onNavigate={selectSidebarItem}
|
||||
onNewSessionInWorkspace={startSessionInWorkspace}
|
||||
onResumeSession={sessionId => navigate(sessionRoute(sessionId))}
|
||||
onSendToAll={sendToAllProfiles}
|
||||
/>
|
||||
)
|
||||
|
||||
|
||||
@@ -9,10 +9,17 @@ import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-ima
|
||||
import { setSessionYolo } from '@/lib/yolo-session'
|
||||
import { clearComposerAttachments, clearComposerDraft } from '@/store/composer'
|
||||
import { clearQueuedPrompts } from '@/store/composer-queue'
|
||||
import { requestGatewayForProfile } from '@/store/gateway'
|
||||
import { $pinnedSessionIds } from '@/store/layout'
|
||||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
import {
|
||||
$activeGatewayProfile,
|
||||
$newChatProfile,
|
||||
$profiles,
|
||||
ensureGatewayProfile,
|
||||
normalizeProfileKey
|
||||
} from '@/store/profile'
|
||||
import {
|
||||
$currentCwd,
|
||||
$messages,
|
||||
@@ -171,13 +178,16 @@ function upsertOptimisticSession(
|
||||
created: SessionCreateResponse,
|
||||
id: string,
|
||||
title: string | null = null,
|
||||
preview: string | null = null
|
||||
preview: string | null = null,
|
||||
profile?: string
|
||||
) {
|
||||
const now = Date.now() / 1000
|
||||
// Stamp the profile the session was just created on (= the live gateway's
|
||||
// profile) so the scoped sidebar shows the new row immediately instead of
|
||||
// filtering it out as "default" until the aggregator re-fetches.
|
||||
const profileKey = normalizeProfileKey($activeGatewayProfile.get())
|
||||
// Stamp the profile the session was just created on so the scoped sidebar
|
||||
// shows the new row immediately instead of filtering it out as "default"
|
||||
// until the aggregator re-fetches. Defaults to the live gateway's profile;
|
||||
// "send to all" passes each target profile explicitly (it creates sessions
|
||||
// off other profiles' sockets, not the active one).
|
||||
const profileKey = normalizeProfileKey(profile ?? $activeGatewayProfile.get())
|
||||
|
||||
const session: SessionInfo = {
|
||||
cwd: created.info?.cwd ?? null,
|
||||
@@ -393,6 +403,69 @@ export function useSessionActions({
|
||||
]
|
||||
)
|
||||
|
||||
// Fan one prompt into EVERY profile at once. For each profile we open (or
|
||||
// reuse) its background gateway, create a fresh session there, register it
|
||||
// exactly like a foreground send (runtime↔stored mapping + optimistic row +
|
||||
// busy flag) so it streams, survives sidebar merges, and clears on completion,
|
||||
// then submit the text — all without touching the user's current session.
|
||||
//
|
||||
// Profiles are walked SEQUENTIALLY: a cold profile lazily boots its own Hermes
|
||||
// backend, and the Electron port picker races if several boot at once (they
|
||||
// grab the same free port and all but one dies). Serial is correct and gentle
|
||||
// on the machine; the turns still run concurrently server-side.
|
||||
const sendToAllProfiles = useCallback(
|
||||
async (text: string): Promise<number> => {
|
||||
const body = text.trim()
|
||||
|
||||
if (!body) {
|
||||
return 0
|
||||
}
|
||||
|
||||
let sent = 0
|
||||
let failed = 0
|
||||
|
||||
for (const profile of $profiles.get()) {
|
||||
const key = normalizeProfileKey(profile.name)
|
||||
let runtimeId: null | string = null
|
||||
let stored: null | string = null
|
||||
|
||||
try {
|
||||
const created = await requestGatewayForProfile<SessionCreateResponse>(key, 'session.create', { cols: 96 })
|
||||
runtimeId = created.session_id
|
||||
stored = created.stored_session_id ?? null
|
||||
ensureSessionState(runtimeId, stored)
|
||||
|
||||
if (stored) {
|
||||
upsertOptimisticSession(created, stored, null, body, key)
|
||||
}
|
||||
|
||||
updateSessionState(runtimeId, state => ({ ...state, awaitingResponse: true, busy: true }), stored)
|
||||
await requestGatewayForProfile(key, 'prompt.submit', { session_id: runtimeId, text: body })
|
||||
sent += 1
|
||||
} catch {
|
||||
// If create landed but submit didn't, drop the busy flag so the row
|
||||
// doesn't spin forever waiting on a turn that never started.
|
||||
if (runtimeId) {
|
||||
updateSessionState(runtimeId, state => ({ ...state, awaitingResponse: false, busy: false }), stored)
|
||||
}
|
||||
|
||||
failed += 1
|
||||
}
|
||||
}
|
||||
|
||||
if (sent) {
|
||||
notify({ durationMs: 2_500, kind: 'success', message: `Sent to ${sent} profile${sent === 1 ? '' : 's'}` })
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
notify({ durationMs: 4_000, kind: 'error', message: `${failed} profile${failed === 1 ? '' : 's'} failed` })
|
||||
}
|
||||
|
||||
return sent
|
||||
},
|
||||
[ensureSessionState, updateSessionState]
|
||||
)
|
||||
|
||||
const selectSidebarItem = useCallback(
|
||||
(item: SidebarNavItem) => {
|
||||
if (item.action === 'new-session') {
|
||||
@@ -847,6 +920,7 @@ export function useSessionActions({
|
||||
removeSession,
|
||||
resumeSession,
|
||||
selectSidebarItem,
|
||||
sendToAllProfiles,
|
||||
startFreshSessionDraft
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +215,37 @@ export async function ensureGatewayForProfile(profile: string): Promise<void> {
|
||||
setActive(key)
|
||||
}
|
||||
|
||||
// Run one RPC against a specific profile's gateway WITHOUT moving the active
|
||||
// pointer — opening (and keeping) its background socket if needed. Used by
|
||||
// "send to all", which fires a prompt into every profile's backend while the
|
||||
// user stays on their current session. The primary is the fast path.
|
||||
export async function requestGatewayForProfile<T>(
|
||||
profile: string,
|
||||
method: string,
|
||||
params: Record<string, unknown> = {}
|
||||
): Promise<T> {
|
||||
const key = normKey(profile)
|
||||
|
||||
if (key === primaryProfile) {
|
||||
if (!primaryGateway) {
|
||||
throw new Error('Hermes gateway unavailable')
|
||||
}
|
||||
|
||||
return primaryGateway.request<T>(method, params)
|
||||
}
|
||||
|
||||
const entry = secondaries.get(key) ?? createSecondary(key)
|
||||
entry.wantOpen = true
|
||||
|
||||
if (!isOpen(entry.gateway)) {
|
||||
clearTimer(entry)
|
||||
entry.reconnectAttempt = 0
|
||||
await openSecondary(entry)
|
||||
}
|
||||
|
||||
return entry.gateway.request<T>(method, params)
|
||||
}
|
||||
|
||||
// Reconnect the active gateway after a transient request failure. Primary
|
||||
// reconnects are owned by use-gateway-boot, so we only drive secondaries here.
|
||||
export async function ensureActiveGatewayOpen(): Promise<HermesGateway | null> {
|
||||
|
||||
Reference in New Issue
Block a user