Compare commits

...

4 Commits

Author SHA1 Message Date
Brooklyn Nicholson
12a2d5f435 fix(desktop): clear busy flag when a send-to-all submit fails
If session.create lands but prompt.submit throws (backend died
mid-broadcast), reset the session's busy/awaiting flags so the sidebar
row doesn't spin forever waiting on a turn that never started.
2026-06-04 21:08:07 -05:00
Brooklyn Nicholson
221d6eb63e refactor(desktop): broadcast send-to-all through the session hook
Move sendToAllProfiles from the profile store into useSessionActions so
each broadcast session registers exactly like a foreground send: runtime
to stored mapping, optimistic sidebar row stamped with its own profile,
and a busy flag. Without this the background sessions never streamed into
the sidebar (no row, and merges culled them) so a broadcast looked like a
no-op even in the All-profiles view.

Also rename the rail overflow's "Settings" item to "Manage" (account
codicon).
2026-06-04 21:07:13 -05:00
Brooklyn Nicholson
07a1b15974 fix(desktop): serialize send-to-all spawns to dodge the port race
Broadcasting to N cold profiles fired N backend spawns at once, and the
Electron port picker handed every concurrent spawn the same free port —
so all but one died with a bind error ("exited before it became ready").
Walk profiles sequentially: each cold backend finishes binding before the
next is picked, which also avoids stampeding the machine with N Python
processes. The dialog now dispatches fire-and-forget (closes immediately;
progress arrives as toasts) since the serial boot can take a few seconds.
2026-06-04 20:54:24 -05:00
Brooklyn Nicholson
03c9bbfb54 feat(desktop): "send to all" broadcast from the profile rail overflow
Turn the rail's "…" pill into a menu (Send to all / Settings). "Send to
all" opens a bare composer dialog that fans one message into every
profile at once — each profile's background gateway is opened (or reused)
via the registry, a fresh session is created there, and the prompt is
submitted, without moving the user off their current session. Turns run
concurrently server-side and stream into the background sockets; the new
sessions surface in the all-profiles view as they persist. The dialog
closes itself on dispatch.

Adds requestGatewayForProfile() to the gateway registry: run one RPC
against a named profile's socket without changing the active pointer.
2026-06-04 20:51:06 -05:00
6 changed files with 243 additions and 10 deletions

View File

@@ -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>

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -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}
/>
)

View File

@@ -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
}
}

View File

@@ -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> {