mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-21 12:55:09 +00:00
* feat(agent): /home composer parity with image attachments
The /home composer used the same ConversationInput component as the
chat screen but passed attachmentsEnabled={false}, and the home →
chat handoff was a URL search param `?q=<text>` that physically
can't carry binary attachments. Pasting a screenshot at /home did
nothing.
Add a small in-memory registry (pending-initial-message.ts) as the
rich-data side channel for the same navigation: the home composer
writes { agentId, text, attachments } there before navigating; the
chat screen consumes it on mount and replays through the existing
harness send() path that already supports attachments. URL `?q=`
stays for shareable text-only prompts; the registry wins when both
are present. Module-scope, 10s TTL, destructive consume.
Net: home is now flagged attachmentsEnabled={true}; users can paste,
drag, or pick image files at /home and they survive the navigation
into the chat screen with previews intact.
* docs(agent): clarify why initial-message ref reset is safe post-registry-fire
82 lines
2.5 KiB
TypeScript
82 lines
2.5 KiB
TypeScript
import type { StagedAttachment } from '@/lib/attachments'
|
|
|
|
/**
|
|
* Same-tab in-memory handoff between the `/home` composer and the
|
|
* chat screen at `/home/agents/:agentId`. URL search params (`?q=`)
|
|
* carry the text fine, but cannot carry binary attachments — a multi-
|
|
* megabyte image dataUrl would explode URL length limits and round-
|
|
* trip badly. This module is the rich-data side channel for the same
|
|
* navigation: the composer writes here, the chat screen reads here on
|
|
* mount.
|
|
*
|
|
* Intentionally module-scope. Same render tree, same tab — no need
|
|
* for sessionStorage (which would force JSON-serialising the dataUrls
|
|
* and re-parsing on the read side). Cross-tab handoff is out of
|
|
* scope: the user typing at home in tab A and switching to tab B's
|
|
* chat would surface an empty registry there, which is the correct
|
|
* behaviour.
|
|
*/
|
|
|
|
export interface PendingInitialMessage {
|
|
agentId: string
|
|
text: string
|
|
attachments: StagedAttachment[]
|
|
createdAt: number
|
|
}
|
|
|
|
/**
|
|
* 10s TTL on the entry. A stale entry from a back-button journey
|
|
* shouldn't fire on a future visit; if real-world latency makes 10s
|
|
* too tight under slow harness boot, bump but never make it
|
|
* indefinite.
|
|
*/
|
|
const PENDING_TTL_MS = 10_000
|
|
|
|
let pending: PendingInitialMessage | null = null
|
|
let pendingTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
function clearPending(): void {
|
|
pending = null
|
|
if (pendingTimer !== null) {
|
|
clearTimeout(pendingTimer)
|
|
pendingTimer = null
|
|
}
|
|
}
|
|
|
|
export function setPendingInitialMessage(payload: PendingInitialMessage): void {
|
|
// Defensive: the home composer should never call this without an
|
|
// agent selected. If it somehow does, no-op rather than holding a
|
|
// payload we can't route.
|
|
if (!payload.agentId) return
|
|
clearPending()
|
|
pending = payload
|
|
pendingTimer = setTimeout(clearPending, PENDING_TTL_MS)
|
|
}
|
|
|
|
/**
|
|
* Destructive read. Returns the entry only if `agentId` matches and
|
|
* the entry is fresh; clears the entry on success so Strict-Mode
|
|
* double-invokes can't double-send.
|
|
*/
|
|
export function consumePendingInitialMessage(
|
|
agentId: string,
|
|
): PendingInitialMessage | null {
|
|
if (!pending) return null
|
|
if (pending.agentId !== agentId) return null
|
|
if (Date.now() - pending.createdAt >= PENDING_TTL_MS) {
|
|
clearPending()
|
|
return null
|
|
}
|
|
const entry = pending
|
|
clearPending()
|
|
return entry
|
|
}
|
|
|
|
/**
|
|
* Non-mutating read for tests. Production code should never need this
|
|
* — use `consume` and own the lifecycle.
|
|
*/
|
|
export function peekPendingInitialMessage(): PendingInitialMessage | null {
|
|
return pending
|
|
}
|