From 6bcfe3a19847dc314c604fa996dafaf9dfd0b2c3 Mon Sep 17 00:00:00 2001
From: thelg4
Date: Wed, 15 Jul 2026 17:47:17 -0400
Subject: [PATCH 001/238] feat(chat): AI agent chat panel (dev-only)
Docked right-column chat talking to the ai-agent service over SSE:
- ChatPanel/ChatMessages/ChatToolCalls/ChatApproval/ChatInput components
- Persisted chat model (transcript, streaming, write-tool confirmations)
- Panel width folds into sidePanelWidth so all panel layouts reflow
- Expand toggle (400px/640px), full-screen overlay below single-panel width
- Vite dev proxy /agent -> :3001 (CSP-safe); VITE_AGENT_URL for deploys
- react-markdown + remark-gfm for assistant replies
- Dev-gated: header robot toggle and panel render only in dev builds
---
frontend/package.json | 2 +
frontend/src/components/App.tsx | 17 +-
frontend/src/components/Chat/ChatApproval.tsx | 32 ++++
frontend/src/components/Chat/ChatInput.tsx | 65 +++++++
.../src/components/Chat/ChatMessageItem.tsx | 83 +++++++++
frontend/src/components/Chat/ChatMessages.tsx | 36 ++++
frontend/src/components/Chat/ChatPanel.tsx | 88 +++++++++
.../src/components/Chat/ChatToolCalls.tsx | 48 +++++
frontend/src/components/Header/Header.tsx | 13 +-
frontend/src/constants.ts | 2 +
frontend/src/models/chat.ts | 167 ++++++++++++++++++
frontend/src/models/index.ts | 3 +
frontend/src/services/agent.ts | 81 +++++++++
frontend/src/store.ts | 1 +
frontend/vite.config.ts | 12 ++
15 files changed, 648 insertions(+), 2 deletions(-)
create mode 100644 frontend/src/components/Chat/ChatApproval.tsx
create mode 100644 frontend/src/components/Chat/ChatInput.tsx
create mode 100644 frontend/src/components/Chat/ChatMessageItem.tsx
create mode 100644 frontend/src/components/Chat/ChatMessages.tsx
create mode 100644 frontend/src/components/Chat/ChatPanel.tsx
create mode 100644 frontend/src/components/Chat/ChatToolCalls.tsx
create mode 100644 frontend/src/models/chat.ts
create mode 100644 frontend/src/services/agent.ts
diff --git a/frontend/package.json b/frontend/package.json
index c541bfb49..80bca7e20 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -61,10 +61,12 @@
"react-dropzone": "^14.3.8",
"react-gtm-module": "^2.0.11",
"react-i18next": "^12.3.1",
+ "react-markdown": "^9.0.1",
"react-redux": "^9.2.0",
"react-router-dom": "^5.3.4",
"react-select": "^5.10.2",
"react-string-replace": "^1.1.1",
+ "remark-gfm": "^4.0.0",
"reaptcha": "^1.12.1",
"reconnecting-websocket": "^4.4.0",
"redux": "^5.0.1",
diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx
index 775b14416..74220ca2f 100644
--- a/frontend/src/components/App.tsx
+++ b/frontend/src/components/App.tsx
@@ -17,6 +17,9 @@ import {
ORGANIZATION_BAR_WIDTH,
REGEX_FIRST_PATH,
SHOW_TRIPLE_PANEL_WIDTH,
+ CHAT_PANEL_WIDTH,
+ CHAT_PANEL_WIDTH_EXPANDED,
+ MODE,
} from '../constants'
import { State, Dispatch } from '../store'
import { useMediaQuery, Box } from '@mui/material'
@@ -27,6 +30,7 @@ import { SidebarMenu } from './SidebarMenu'
import { SignInPage } from '../pages/SignInPage'
import { BottomMenu } from './BottomMenu'
import { Sidebar } from './Sidebar'
+import { ChatPanel } from './Chat/ChatPanel'
import { Router } from '../routers/Router'
import { Page } from '../pages/Page'
import { Logo } from '@common/brand/Logo'
@@ -49,13 +53,23 @@ export const App: React.FC = () => {
const installed = useSelector((state: State) => state.binaries.installed)
const waitMessage = useSelector((state: State) => state.ui.waitMessage)
const showOrgs = useSelector((state: State) => !!state.accounts.membership.length)
+ const chatOpen = useSelector((state: State) => state.chat.open)
+ const chatExpanded = useSelector((state: State) => state.chat.expanded)
const reseller = useSelector(selectResellerRef)
const dispatch = useDispatch()
const hideSidebar = useMediaQuery(`(max-width:${HIDE_SIDEBAR_WIDTH}px)`)
const singlePanel = useMediaQuery(`(max-width:${HIDE_TWO_PANEL_WIDTH}px)`)
const triplePanel = useMediaQuery(`(min-width:${SHOW_TRIPLE_PANEL_WIDTH}px)`)
const mobile = useMediaQuery(`(max-width:${MOBILE_WIDTH}px)`)
- const sidePanelWidth = hideSidebar ? 0 : SIDEBAR_WIDTH + (showOrgs ? ORGANIZATION_BAR_WIDTH : 0)
+ // The open chat column reserves layout space the same way the sidebar does,
+ // so Panel/DoublePanel/TriplePanel all reflow and clamp their resize math to it
+ const chatPanelWidth =
+ MODE === 'development' && chatOpen && !singlePanel
+ ? chatExpanded
+ ? CHAT_PANEL_WIDTH_EXPANDED
+ : CHAT_PANEL_WIDTH
+ : 0
+ const sidePanelWidth = (hideSidebar ? 0 : SIDEBAR_WIDTH + (showOrgs ? ORGANIZATION_BAR_WIDTH : 0)) + chatPanelWidth
const isRootMenu = location.pathname.match(REGEX_FIRST_PATH)?.[0] === location.pathname
const showBottomMenu = (mobile || browser.isMobile) && isRootMenu && hideSidebar
const needsUserHydration = authenticated && !user
@@ -132,6 +146,7 @@ export const App: React.FC = () => {
>
{hideSidebar ? : }
+ {MODE === 'development' && }
{showBottomMenu && }
diff --git a/frontend/src/components/Chat/ChatApproval.tsx b/frontend/src/components/Chat/ChatApproval.tsx
new file mode 100644
index 000000000..37eaf9c22
--- /dev/null
+++ b/frontend/src/components/Chat/ChatApproval.tsx
@@ -0,0 +1,32 @@
+import React from 'react'
+import { Paper, Typography, Button, Box } from '@mui/material'
+
+type Props = {
+ toolName: string
+ input: Record
+ onRespond: (approved: boolean) => void
+}
+
+/* Inline card shown when the agent pauses on a write tool awaiting approval */
+export const ChatApproval: React.FC = ({ toolName, input, onRespond }) => (
+
+
+ The agent wants to run {toolName}
+
+
+ {JSON.stringify(input, null, 2)}
+
+
+
+
+
+
+)
diff --git a/frontend/src/components/Chat/ChatInput.tsx b/frontend/src/components/Chat/ChatInput.tsx
new file mode 100644
index 000000000..eda003026
--- /dev/null
+++ b/frontend/src/components/Chat/ChatInput.tsx
@@ -0,0 +1,65 @@
+import React, { useState } from 'react'
+import { Box, InputBase } from '@mui/material'
+import { IconButton } from '../../buttons/IconButton'
+
+type Props = {
+ disabled: boolean
+ streaming: boolean
+ onSend: (text: string) => void
+ onStop: () => void
+}
+
+export const ChatInput: React.FC = ({ disabled, streaming, onSend, onStop }) => {
+ const [text, setText] = useState('')
+ const submit = () => {
+ const trimmed = text.trim()
+ if (!trimmed || disabled || streaming) return
+ onSend(trimmed)
+ setText('')
+ }
+ return (
+
+
+ setText(event.target.value)}
+ onKeyDown={event => {
+ if (event.key === 'Enter' && !event.shiftKey) {
+ event.preventDefault()
+ submit()
+ }
+ }}
+ />
+ {streaming ? (
+
+ ) : (
+
+ )}
+
+
+ )
+}
diff --git a/frontend/src/components/Chat/ChatMessageItem.tsx b/frontend/src/components/Chat/ChatMessageItem.tsx
new file mode 100644
index 000000000..02a510244
--- /dev/null
+++ b/frontend/src/components/Chat/ChatMessageItem.tsx
@@ -0,0 +1,83 @@
+import React from 'react'
+import Markdown from 'react-markdown'
+import remarkGfm from 'remark-gfm'
+import { Box, Typography } from '@mui/material'
+import { ChatTranscriptMessage } from '../../models/chat'
+import { ChatToolCalls } from './ChatToolCalls'
+
+export const ChatMessageItem: React.FC<{ message: ChatTranscriptMessage }> = ({ message }) => {
+ if (message.role === 'user')
+ return (
+
+
+
+ {message.text}
+
+
+
+ )
+
+ return (
+
+
+
+ {message.text}
+
+ {message.interrupted && (
+
+ Interrupted
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/Chat/ChatMessages.tsx b/frontend/src/components/Chat/ChatMessages.tsx
new file mode 100644
index 000000000..adb48fce5
--- /dev/null
+++ b/frontend/src/components/Chat/ChatMessages.tsx
@@ -0,0 +1,36 @@
+import React, { useEffect, useRef, useState } from 'react'
+import { Box } from '@mui/material'
+import { ChatTranscriptMessage } from '../../models/chat'
+import { ChatMessageItem } from './ChatMessageItem'
+
+type Props = {
+ messages: ChatTranscriptMessage[]
+ streaming: boolean
+ children?: React.ReactNode
+}
+
+export const ChatMessages: React.FC = ({ messages, streaming, children }) => {
+ const ref = useRef(null)
+ const [pinned, setPinned] = useState(true)
+
+ // Follow the stream, but release when the user scrolls up to read
+ useEffect(() => {
+ if (pinned) ref.current?.scrollTo({ top: ref.current.scrollHeight })
+ }, [messages, streaming, pinned, children])
+
+ return (
+ {
+ const el = ref.current
+ if (el) setPinned(el.scrollHeight - el.scrollTop - el.clientHeight < 40)
+ }}
+ sx={{ flexGrow: 1, overflowY: 'auto', paddingX: 2 }}
+ >
+ {messages.map((message, index) => (
+
+ ))}
+ {children}
+
+ )
+}
diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx
new file mode 100644
index 000000000..8f6c9774a
--- /dev/null
+++ b/frontend/src/components/Chat/ChatPanel.tsx
@@ -0,0 +1,88 @@
+import React, { useEffect } from 'react'
+import { useSelector, useDispatch } from 'react-redux'
+import { Box, Typography } from '@mui/material'
+import { State, Dispatch } from '../../store'
+import { CHAT_PANEL_WIDTH, CHAT_PANEL_WIDTH_EXPANDED } from '../../constants'
+import { IconButton } from '../../buttons/IconButton'
+import { ChatMessages } from './ChatMessages'
+import { ChatApproval } from './ChatApproval'
+import { ChatInput } from './ChatInput'
+import { Notice } from '../Notice'
+
+export const ChatPanel: React.FC = () => {
+ const chat = useSelector((state: State) => state.chat)
+ const singlePanel = useSelector((state: State) => state.ui.layout.singlePanel)
+ const dispatch = useDispatch()
+
+ useEffect(() => {
+ if (chat.open) {
+ dispatch.chat.resetTransient()
+ dispatch.chat.checkHealth()
+ }
+ }, [chat.open])
+
+ if (!chat.open) return null
+
+ return (
+
+
+
+ New Chat
+
+ {!singlePanel && (
+ dispatch.chat.set({ expanded: !chat.expanded })}
+ />
+ )}
+ dispatch.chat.clearConversation()} />
+ dispatch.chat.set({ open: false })} />
+
+ {chat.health === 'unreachable' && (
+
+ Agent unreachable — is the dev service running on :3001?
+
+ )}
+
+ {chat.pendingConfirmation && (
+ dispatch.chat.confirm(approved)}
+ />
+ )}
+ {chat.error && (
+ dispatch.chat.set({ error: null })}>
+ {chat.error}
+
+ )}
+
+ dispatch.chat.send(text)}
+ onStop={() => dispatch.chat.stop()}
+ />
+
+ )
+}
diff --git a/frontend/src/components/Chat/ChatToolCalls.tsx b/frontend/src/components/Chat/ChatToolCalls.tsx
new file mode 100644
index 000000000..5ddd8d58c
--- /dev/null
+++ b/frontend/src/components/Chat/ChatToolCalls.tsx
@@ -0,0 +1,48 @@
+import React, { useState } from 'react'
+import { Box, ButtonBase, Collapse, Typography, CircularProgress } from '@mui/material'
+import { ChatToolCall } from '../../models/chat'
+import { Icon } from '../Icon'
+
+export const ChatToolCalls: React.FC<{ toolCalls: ChatToolCall[] }> = ({ toolCalls }) => {
+ const [open, setOpen] = useState(false)
+ if (!toolCalls.length) return null
+ const running = toolCalls.some(c => c.status === 'running')
+ return (
+
+ setOpen(!open)} sx={{ borderRadius: 1, paddingX: 0.5, color: 'grayDark.main' }}>
+ {running ? (
+
+ ) : (
+
+ )}
+
+ Used {toolCalls.length} tool{toolCalls.length === 1 ? '' : 's'}
+
+
+
+ {toolCalls.map(call => (
+
+
+ {call.name}
+ {call.status === 'running' ? ' …' : ''}
+
+ {call.result && (
+
+ {call.result.slice(0, 200)}
+
+ )}
+
+ ))}
+
+
+ )
+}
diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx
index 85ab21f08..3362dc64a 100644
--- a/frontend/src/components/Header/Header.tsx
+++ b/frontend/src/components/Header/Header.tsx
@@ -1,4 +1,4 @@
-import { REGEX_FIRST_PATH, HIDE_SIDEBAR_WIDTH, MOBILE_WIDTH } from '../../constants'
+import { REGEX_FIRST_PATH, HIDE_SIDEBAR_WIDTH, MOBILE_WIDTH, MODE } from '../../constants'
import React, { useState, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import useNavigationUp from '../../hooks/useNavigationUp'
@@ -29,6 +29,7 @@ export const Header: React.FC = ({ panels = 1 }) => {
const { t } = useTranslation()
const { searched } = useSelector(selectDeviceModelAttributes)
const permissions = useSelector(selectPermissions)
+ const chatOpen = useSelector((state: State) => state.chat.open)
const layout = useSelector((state: State) => state.ui.layout)
const overlapHeader = layout.hideSidebar && browser.isElectron && browser.isMac
@@ -80,6 +81,16 @@ export const Header: React.FC = ({ panels = 1 }) => {
color="grayDarker"
/>
)}
+ {MODE === 'development' && (
+ dispatch.chat.set({ open: !chatOpen })}
+ />
+ )}
{!showSearch && }
{sidebarHidden && (
diff --git a/frontend/src/constants.ts b/frontend/src/constants.ts
index d66ef15ec..71209cb27 100644
--- a/frontend/src/constants.ts
+++ b/frontend/src/constants.ts
@@ -91,6 +91,8 @@ export const SIGN_OUT_BACKEND_TIMEOUT = 3000
export const MAX_CONNECTION_NAME_LENGTH = 62
export const MAX_DESCRIPTION_LENGTH = 1024
export const SIDEBAR_WIDTH = 250
+export const CHAT_PANEL_WIDTH = 400
+export const CHAT_PANEL_WIDTH_EXPANDED = 640
export const ORGANIZATION_BAR_WIDTH = 70
export const HIDE_SIDEBAR_WIDTH = 1150
export const HIDE_TWO_PANEL_WIDTH = 750
diff --git a/frontend/src/models/chat.ts b/frontend/src/models/chat.ts
new file mode 100644
index 000000000..8f4e27850
--- /dev/null
+++ b/frontend/src/models/chat.ts
@@ -0,0 +1,167 @@
+import { createModel } from '@rematch/core'
+import { RootModel } from '.'
+import { streamChat, confirmTool, agentHealth, AgentEvent, AgentMessageParam } from '../services/agent'
+
+export type ChatToolCall = {
+ id: string
+ name: string
+ input: Record
+ status: 'running' | 'done' | 'error'
+ result?: string
+}
+
+export type ChatTranscriptMessage =
+ | { role: 'user'; text: string }
+ | { role: 'assistant'; text: string; toolCalls: ChatToolCall[]; interrupted?: boolean }
+
+export type IChatState = {
+ open: boolean
+ expanded: boolean
+ messages: ChatTranscriptMessage[]
+ conversationId: string
+ streaming: boolean
+ pendingConfirmation: { toolUseId: string; toolName: string; input: Record } | null
+ error: string | null
+ health: 'unknown' | 'ok' | 'unreachable'
+}
+
+export const defaultChatState: IChatState = {
+ open: false,
+ expanded: false,
+ messages: [],
+ conversationId: '',
+ streaming: false,
+ pendingConfirmation: null,
+ error: null,
+ health: 'unknown',
+}
+
+/* Reduce one agent stream event into chat state. Mutates the immer draft. */
+function applyAgentEvent(state: IChatState, event: AgentEvent): IChatState {
+ const last = state.messages[state.messages.length - 1]
+ let assistant = last?.role === 'assistant' ? last : undefined
+ const ensureAssistant = () => {
+ if (!assistant) {
+ assistant = { role: 'assistant', text: '', toolCalls: [] }
+ state.messages.push(assistant)
+ }
+ return assistant
+ }
+
+ switch (event.type) {
+ case 'text_delta':
+ ensureAssistant().text += event.text
+ break
+ case 'tool_call_start':
+ ensureAssistant().toolCalls.push({ id: event.id, name: event.name, input: event.input, status: 'running' })
+ break
+ case 'tool_call_result': {
+ const call = assistant?.toolCalls.find(c => c.id === event.id)
+ if (call) {
+ call.status = event.isError ? 'error' : 'done'
+ call.result = event.result
+ }
+ break
+ }
+ case 'confirmation_required':
+ state.pendingConfirmation = { toolUseId: event.id, toolName: event.name, input: event.input }
+ break
+ case 'done':
+ state.streaming = false
+ state.pendingConfirmation = null
+ break
+ case 'error':
+ state.error = event.message
+ state.streaming = false
+ state.pendingConfirmation = null
+ if (assistant) assistant.interrupted = true
+ break
+ }
+ return state
+}
+
+/* The agent service is stateless: resend the transcript as role/content pairs each turn */
+function toMessageParams(messages: ChatTranscriptMessage[]): AgentMessageParam[] {
+ return messages.filter(m => m.text.trim().length > 0).map(m => ({ role: m.role, content: m.text }))
+}
+
+let abortController: AbortController | null = null
+
+export default createModel()({
+ state: { ...defaultChatState },
+ effects: dispatch => ({
+ async send(text: string, state) {
+ if (state.chat.streaming || state.chat.pendingConfirmation) return
+ const conversationId = state.chat.conversationId || crypto.randomUUID()
+ const messages = toMessageParams([...state.chat.messages, { role: 'user', text }])
+ dispatch.chat.addUserMessage(text)
+ dispatch.chat.set({ conversationId, streaming: true, error: null })
+ abortController = new AbortController()
+ try {
+ await streamChat({
+ conversationId,
+ messages,
+ signal: abortController.signal,
+ onEvent: event => dispatch.chat.applyEvent(event),
+ })
+ } catch (error) {
+ if ((error as Error).name !== 'AbortError')
+ dispatch.chat.applyEvent({ type: 'error', message: (error as Error).message })
+ } finally {
+ abortController = null
+ dispatch.chat.set({ streaming: false })
+ }
+ },
+ async confirm(approved: boolean, state) {
+ const pending = state.chat.pendingConfirmation
+ if (!pending) return
+ try {
+ await confirmTool({
+ conversationId: state.chat.conversationId,
+ toolUseId: pending.toolUseId,
+ approved,
+ })
+ dispatch.chat.set({ pendingConfirmation: null })
+ } catch (error) {
+ dispatch.chat.set({ error: (error as Error).message })
+ }
+ },
+ async stop() {
+ abortController?.abort()
+ abortController = null
+ dispatch.chat.set({ streaming: false, pendingConfirmation: null })
+ },
+ async checkHealth() {
+ dispatch.chat.set({ health: (await agentHealth()) ? 'ok' : 'unreachable' })
+ },
+ }),
+ reducers: {
+ set(state: IChatState, params: Partial) {
+ Object.assign(state, params)
+ return state
+ },
+ addUserMessage(state: IChatState, text: string) {
+ state.messages.push({ role: 'user', text })
+ return state
+ },
+ applyEvent(state: IChatState, event: AgentEvent) {
+ return applyAgentEvent(state, event)
+ },
+ // Streaming state must not survive a reload — called when the panel mounts
+ resetTransient(state: IChatState) {
+ state.streaming = false
+ state.pendingConfirmation = null
+ state.error = null
+ state.health = 'unknown'
+ return state
+ },
+ clearConversation(state: IChatState) {
+ state.messages = []
+ state.conversationId = ''
+ state.streaming = false
+ state.pendingConfirmation = null
+ state.error = null
+ return state
+ },
+ },
+})
diff --git a/frontend/src/models/index.ts b/frontend/src/models/index.ts
index 140dfea56..a99ba139d 100644
--- a/frontend/src/models/index.ts
+++ b/frontend/src/models/index.ts
@@ -13,6 +13,7 @@ import backend from './backend'
import billing from './billing'
import binaries from './binaries'
import bluetooth from './bluetooth'
+import chat from './chat'
import connections from './connections'
import contacts from './contacts'
import devices from './devices'
@@ -50,6 +51,7 @@ export interface RootModel extends Models {
billing: typeof billing
binaries: typeof binaries
bluetooth: typeof bluetooth
+ chat: typeof chat
connections: typeof connections
contacts: typeof contacts
devices: typeof devices
@@ -88,6 +90,7 @@ export const models: RootModel = {
billing,
binaries,
bluetooth,
+ chat,
connections,
contacts,
devices,
diff --git a/frontend/src/services/agent.ts b/frontend/src/services/agent.ts
new file mode 100644
index 000000000..d4016ac0b
--- /dev/null
+++ b/frontend/src/services/agent.ts
@@ -0,0 +1,81 @@
+/**
+ * Client for the ai-agent service (REST + SSE). The service is stateless:
+ * the client holds the transcript and resends it each turn.
+ */
+
+// Dev: relative path served by the vite proxy (same-origin, CSP-clean).
+// Staging/prod: set VITE_AGENT_URL to the deployed agent service domain.
+export const AGENT_URL = import.meta.env.VITE_AGENT_URL || '/agent'
+
+export type AgentEvent =
+ | { type: 'text_delta'; text: string }
+ | { type: 'tool_call_start'; id: string; name: string; input: Record }
+ | { type: 'tool_call_result'; id: string; name: string; result: string; isError: boolean; durationMs: number }
+ | { type: 'confirmation_required'; id: string; name: string; input: Record }
+ | { type: 'done'; stopReason: string | null }
+ | { type: 'error'; message: string }
+
+export type AgentMessageParam = { role: 'user' | 'assistant'; content: string }
+
+/* Stream one chat turn. Events arrive as SSE: `event: \ndata: \n\n` */
+export async function streamChat(options: {
+ conversationId: string
+ messages: AgentMessageParam[]
+ signal?: AbortSignal
+ onEvent: (event: AgentEvent) => void
+}): Promise {
+ const { conversationId, messages, signal, onEvent } = options
+ const response = await fetch(`${AGENT_URL}/api/chat`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ conversationId, messages }),
+ signal,
+ })
+ if (!response.ok || !response.body) throw new Error(`Agent request failed (${response.status})`)
+
+ const reader = response.body.getReader()
+ const decoder = new TextDecoder()
+ let buffer = ''
+ while (true) {
+ const { done, value } = await reader.read()
+ if (done) break
+ buffer += decoder.decode(value, { stream: true })
+ let index: number
+ while ((index = buffer.indexOf('\n\n')) !== -1) {
+ const block = buffer.slice(0, index)
+ buffer = buffer.slice(index + 2)
+ let event = 'message'
+ const dataLines: string[] = []
+ for (const line of block.split('\n')) {
+ if (line.startsWith('event:')) event = line.slice(6).trim()
+ else if (line.startsWith('data:')) dataLines.push(line.slice(5).trimStart())
+ }
+ if (dataLines.length) onEvent({ type: event, ...JSON.parse(dataLines.join('\n')) } as AgentEvent)
+ }
+ }
+}
+
+/* Approve or deny a write tool the agent paused on */
+export async function confirmTool(options: {
+ conversationId: string
+ toolUseId: string
+ approved: boolean
+}): Promise {
+ const response = await fetch(`${AGENT_URL}/api/chat/confirm`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(options),
+ })
+ if (!response.ok) throw new Error(`Confirm failed (${response.status})`)
+}
+
+export async function agentHealth(): Promise {
+ try {
+ const response = await fetch(`${AGENT_URL}/api/health`)
+ if (!response.ok) return false
+ const body = (await response.json()) as { ok?: boolean }
+ return !!body.ok
+ } catch {
+ return false
+ }
+}
diff --git a/frontend/src/store.ts b/frontend/src/store.ts
index c820815be..53d53143b 100644
--- a/frontend/src/store.ts
+++ b/frontend/src/store.ts
@@ -20,6 +20,7 @@ const persistConfig: PersistConfig = {
'accounts',
'announcements',
'applicationTypes',
+ 'chat',
'connections',
'contacts',
'devices',
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index eaba04531..85a8e7afb 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -24,5 +24,17 @@ export default defineConfig(({ mode }) => ({
resolve: {
alias: { '@common': path.resolve(__dirname, '../common/src') },
},
+ server: {
+ // Dev-only: same-origin path to the local ai-agent service, so the app's
+ // CSP ('self') passes without loosening. Staging/prod set VITE_AGENT_URL
+ // to the deployed agent domain instead — this proxy does not exist in builds.
+ proxy: {
+ '/agent': {
+ target: 'http://localhost:3001',
+ changeOrigin: true,
+ rewrite: p => p.replace(/^\/agent/, ''),
+ },
+ },
+ },
type: 'module',
}))
From 47b61aced93785cb84810e2c6263111740222bec Mon Sep 17 00:00:00 2001
From: thelg4
Date: Wed, 22 Jul 2026 13:08:39 -0400
Subject: [PATCH 002/238] feat(chat): send Hydra bearer token to the agent
service
Stage A of agent auth: attach Authorization from a locally stored token
(pasted from the ai-agent dev harness) on chat/confirm/health calls,
surface 401 reauth_required and mid-turn auth failures as a sign-in
notice with a token paste field. The in-app PKCE flow replaces the
paste field as the token writer in a later stage.
---
frontend/src/components/Chat/ChatPanel.tsx | 31 ++++++++++++++-
frontend/src/models/chat.ts | 39 +++++++++++++++---
frontend/src/services/agent.ts | 46 ++++++++++++++++++----
3 files changed, 101 insertions(+), 15 deletions(-)
diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx
index 8f6c9774a..c2c2a0721 100644
--- a/frontend/src/components/Chat/ChatPanel.tsx
+++ b/frontend/src/components/Chat/ChatPanel.tsx
@@ -1,6 +1,6 @@
-import React, { useEffect } from 'react'
+import React, { useEffect, useState } from 'react'
import { useSelector, useDispatch } from 'react-redux'
-import { Box, Typography } from '@mui/material'
+import { Box, TextField, Typography } from '@mui/material'
import { State, Dispatch } from '../../store'
import { CHAT_PANEL_WIDTH, CHAT_PANEL_WIDTH_EXPANDED } from '../../constants'
import { IconButton } from '../../buttons/IconButton'
@@ -13,6 +13,7 @@ export const ChatPanel: React.FC = () => {
const chat = useSelector((state: State) => state.chat)
const singlePanel = useSelector((state: State) => state.ui.layout.singlePanel)
const dispatch = useDispatch()
+ const [tokenDraft, setTokenDraft] = useState('')
useEffect(() => {
if (chat.open) {
@@ -63,6 +64,32 @@ export const ChatPanel: React.FC = () => {
Agent unreachable — is the dev service running on :3001?
)}
+ {chat.health === 'unauthorized' && (
+
+ {/* Stage A: paste a token from the ai-agent dev harness
+ (`node scripts/hydra-login.mjs token`). Replaced by the in-app
+ sign-in flow in a later stage. */}
+ <>
+ Agent sign-in required.
+ setTokenDraft(event.target.value)}
+ onKeyDown={event => {
+ if (event.key === 'Enter' && tokenDraft.trim()) {
+ dispatch.chat.setToken(tokenDraft)
+ setTokenDraft('')
+ }
+ }}
+ sx={{ marginTop: 1 }}
+ />
+ >
+
+ )}
{chat.pendingConfirmation && (
} | null
error: string | null
- health: 'unknown' | 'ok' | 'unreachable'
+ health: 'unknown' | AgentHealth
}
export const defaultChatState: IChatState = {
@@ -71,7 +80,14 @@ function applyAgentEvent(state: IChatState, event: AgentEvent): IChatState {
state.pendingConfirmation = null
break
case 'error':
- state.error = event.message
+ // The backend prefixes auth failures so the client knows a retry is
+ // pointless until the token is refreshed (e.g. it expired mid-turn).
+ if (event.message.startsWith('reauth_required')) {
+ state.error = 'Agent session expired — sign in again to continue.'
+ state.health = 'unauthorized'
+ } else {
+ state.error = event.message
+ }
state.streaming = false
state.pendingConfirmation = null
if (assistant) assistant.interrupted = true
@@ -105,7 +121,9 @@ export default createModel()({
onEvent: event => dispatch.chat.applyEvent(event),
})
} catch (error) {
- if ((error as Error).name !== 'AbortError')
+ if (error instanceof AgentAuthError)
+ dispatch.chat.set({ error: 'Agent authentication required — sign in to continue.', health: 'unauthorized' })
+ else if ((error as Error).name !== 'AbortError')
dispatch.chat.applyEvent({ type: 'error', message: (error as Error).message })
} finally {
abortController = null
@@ -123,7 +141,9 @@ export default createModel()({
})
dispatch.chat.set({ pendingConfirmation: null })
} catch (error) {
- dispatch.chat.set({ error: (error as Error).message })
+ if (error instanceof AgentAuthError)
+ dispatch.chat.set({ error: 'Agent authentication required — sign in to continue.', health: 'unauthorized' })
+ else dispatch.chat.set({ error: (error as Error).message })
}
},
async stop() {
@@ -132,7 +152,14 @@ export default createModel()({
dispatch.chat.set({ streaming: false, pendingConfirmation: null })
},
async checkHealth() {
- dispatch.chat.set({ health: (await agentHealth()) ? 'ok' : 'unreachable' })
+ dispatch.chat.set({ health: await agentHealth() })
+ },
+ // Stage A: token pasted from the ai-agent dev harness. The in-app Hydra
+ // PKCE flow becomes the writer in a later stage; readers are unchanged.
+ async setToken(token: string) {
+ setAgentToken(token)
+ dispatch.chat.set({ error: null })
+ await dispatch.chat.checkHealth()
},
}),
reducers: {
diff --git a/frontend/src/services/agent.ts b/frontend/src/services/agent.ts
index d4016ac0b..85b17f657 100644
--- a/frontend/src/services/agent.ts
+++ b/frontend/src/services/agent.ts
@@ -7,6 +7,33 @@
// Staging/prod: set VITE_AGENT_URL to the deployed agent service domain.
export const AGENT_URL = import.meta.env.VITE_AGENT_URL || '/agent'
+// Hydra access token for the agent service (AUTH_MODE=hydra). Stage A: the
+// token is pasted in (from `node scripts/hydra-login.mjs token` in the
+// ai-agent repo); the in-app PKCE flow replaces this as the writer later.
+// localStorage matches where Amplify keeps the Cognito session today.
+const AGENT_TOKEN_KEY = 'agentToken'
+
+export const getAgentToken = (): string | null => window.localStorage.getItem(AGENT_TOKEN_KEY)
+
+export function setAgentToken(token: string | null): void {
+ if (token?.trim()) window.localStorage.setItem(AGENT_TOKEN_KEY, token.trim().replace(/^Bearer\s+/i, ''))
+ else window.localStorage.removeItem(AGENT_TOKEN_KEY)
+}
+
+/* The agent rejected our credential (401 reauth_required) — sign in again */
+export class AgentAuthError extends Error {
+ constructor() {
+ super('Agent authentication required')
+ }
+}
+
+function agentHeaders(json = true): Record {
+ const headers: Record = json ? { 'Content-Type': 'application/json' } : {}
+ const token = getAgentToken()
+ if (token) headers.Authorization = `Bearer ${token}`
+ return headers
+}
+
export type AgentEvent =
| { type: 'text_delta'; text: string }
| { type: 'tool_call_start'; id: string; name: string; input: Record }
@@ -27,10 +54,11 @@ export async function streamChat(options: {
const { conversationId, messages, signal, onEvent } = options
const response = await fetch(`${AGENT_URL}/api/chat`, {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+ headers: agentHeaders(),
body: JSON.stringify({ conversationId, messages }),
signal,
})
+ if (response.status === 401) throw new AgentAuthError()
if (!response.ok || !response.body) throw new Error(`Agent request failed (${response.status})`)
const reader = response.body.getReader()
@@ -63,19 +91,23 @@ export async function confirmTool(options: {
}): Promise {
const response = await fetch(`${AGENT_URL}/api/chat/confirm`, {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+ headers: agentHeaders(),
body: JSON.stringify(options),
})
+ if (response.status === 401) throw new AgentAuthError()
if (!response.ok) throw new Error(`Confirm failed (${response.status})`)
}
-export async function agentHealth(): Promise {
+export type AgentHealth = 'ok' | 'unauthorized' | 'unreachable'
+
+export async function agentHealth(): Promise {
try {
- const response = await fetch(`${AGENT_URL}/api/health`)
- if (!response.ok) return false
+ const response = await fetch(`${AGENT_URL}/api/health`, { headers: agentHeaders(false) })
+ if (response.status === 401) return 'unauthorized'
+ if (!response.ok) return 'unreachable'
const body = (await response.json()) as { ok?: boolean }
- return !!body.ok
+ return body.ok ? 'ok' : 'unreachable'
} catch {
- return false
+ return 'unreachable'
}
}
From 1857be03511d8ffd83d3e880f6894502216fd618 Mon Sep 17 00:00:00 2001
From: thelg4
Date: Wed, 22 Jul 2026 13:16:57 -0400
Subject: [PATCH 003/238] feat(chat): in-app Hydra sign-in for the agent (PKCE
+ DCR)
Replaces the pasted-token stopgap: a Sign in button self-registers an
OAuth client (Dynamic Client Registration), redirects to the Hydra
login/consent pages, exchanges the code on return (PKCE), and silently
refreshes the 30m access token before each turn. Register/token calls
ride the new dev-only /hydra vite proxy so no CORS setup is needed;
packaged builds need the origin allow-listed or a main-process exchange.
---
frontend/src/components/Chat/ChatPanel.tsx | 35 ++--
frontend/src/models/chat.ts | 25 ++-
frontend/src/services/agent.ts | 29 +++-
frontend/src/services/hydra.ts | 187 +++++++++++++++++++++
frontend/vite.config.ts | 9 +
5 files changed, 259 insertions(+), 26 deletions(-)
create mode 100644 frontend/src/services/hydra.ts
diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx
index c2c2a0721..47f998598 100644
--- a/frontend/src/components/Chat/ChatPanel.tsx
+++ b/frontend/src/components/Chat/ChatPanel.tsx
@@ -1,6 +1,6 @@
-import React, { useEffect, useState } from 'react'
+import React, { useEffect } from 'react'
import { useSelector, useDispatch } from 'react-redux'
-import { Box, TextField, Typography } from '@mui/material'
+import { Box, Button, Typography } from '@mui/material'
import { State, Dispatch } from '../../store'
import { CHAT_PANEL_WIDTH, CHAT_PANEL_WIDTH_EXPANDED } from '../../constants'
import { IconButton } from '../../buttons/IconButton'
@@ -13,7 +13,12 @@ export const ChatPanel: React.FC = () => {
const chat = useSelector((state: State) => state.chat)
const singlePanel = useSelector((state: State) => state.ui.layout.singlePanel)
const dispatch = useDispatch()
- const [tokenDraft, setTokenDraft] = useState('')
+
+ // Completes a Hydra sign-in redirect if this page load carries ?code —
+ // runs on mount regardless of whether the panel is open
+ useEffect(() => {
+ dispatch.chat.handleSignInCallback()
+ }, [])
useEffect(() => {
if (chat.open) {
@@ -66,27 +71,17 @@ export const ChatPanel: React.FC = () => {
)}
{chat.health === 'unauthorized' && (
- {/* Stage A: paste a token from the ai-agent dev harness
- (`node scripts/hydra-login.mjs token`). Replaced by the in-app
- sign-in flow in a later stage. */}
<>
- Agent sign-in required.
- setTokenDraft(event.target.value)}
- onKeyDown={event => {
- if (event.key === 'Enter' && tokenDraft.trim()) {
- dispatch.chat.setToken(tokenDraft)
- setTokenDraft('')
- }
- }}
+ variant="contained"
+ onClick={() => dispatch.chat.signIn()}
sx={{ marginTop: 1 }}
- />
+ >
+ Sign in with remote.it
+
>
)}
diff --git a/frontend/src/models/chat.ts b/frontend/src/models/chat.ts
index d2902f9ff..a72e1b543 100644
--- a/frontend/src/models/chat.ts
+++ b/frontend/src/models/chat.ts
@@ -10,6 +10,7 @@ import {
AgentHealth,
AgentMessageParam,
} from '../services/agent'
+import { startAgentSignIn, handleAgentSignInCallback, ensureFreshAgentToken } from '../services/hydra'
export type ChatToolCall = {
id: string
@@ -114,6 +115,7 @@ export default createModel()({
dispatch.chat.set({ conversationId, streaming: true, error: null })
abortController = new AbortController()
try {
+ await ensureFreshAgentToken()
await streamChat({
conversationId,
messages,
@@ -134,6 +136,7 @@ export default createModel()({
const pending = state.chat.pendingConfirmation
if (!pending) return
try {
+ await ensureFreshAgentToken()
await confirmTool({
conversationId: state.chat.conversationId,
toolUseId: pending.toolUseId,
@@ -152,10 +155,28 @@ export default createModel()({
dispatch.chat.set({ streaming: false, pendingConfirmation: null })
},
async checkHealth() {
+ await ensureFreshAgentToken()
dispatch.chat.set({ health: await agentHealth() })
},
- // Stage A: token pasted from the ai-agent dev harness. The in-app Hydra
- // PKCE flow becomes the writer in a later stage; readers are unchanged.
+ /* Full-page redirect to the Hydra login (registers a client first if
+ needed); handleSignInCallback picks up the return after reload */
+ async signIn() {
+ try {
+ await startAgentSignIn()
+ } catch (error) {
+ dispatch.chat.set({ error: (error as Error).message })
+ }
+ },
+ /* Complete a sign-in redirect if this page load carries one */
+ async handleSignInCallback() {
+ const result = await handleAgentSignInCallback()
+ if (!result) return
+ if (result.ok) dispatch.chat.set({ error: null, open: true })
+ else dispatch.chat.set({ error: `Agent sign-in failed — ${result.error}`, open: true })
+ await dispatch.chat.checkHealth()
+ },
+ // Dev fallback: token pasted from the ai-agent harness (devtools:
+ // localStorage.agentToken). The sign-in flow is the normal writer.
async setToken(token: string) {
setAgentToken(token)
dispatch.chat.set({ error: null })
diff --git a/frontend/src/services/agent.ts b/frontend/src/services/agent.ts
index 85b17f657..bad2572f0 100644
--- a/frontend/src/services/agent.ts
+++ b/frontend/src/services/agent.ts
@@ -7,11 +7,18 @@
// Staging/prod: set VITE_AGENT_URL to the deployed agent service domain.
export const AGENT_URL = import.meta.env.VITE_AGENT_URL || '/agent'
-// Hydra access token for the agent service (AUTH_MODE=hydra). Stage A: the
-// token is pasted in (from `node scripts/hydra-login.mjs token` in the
-// ai-agent repo); the in-app PKCE flow replaces this as the writer later.
-// localStorage matches where Amplify keeps the Cognito session today.
+// Hydra credentials for the agent service (AUTH_MODE=hydra), written by the
+// in-app sign-in flow (services/hydra.ts) — or a token pasted from the
+// ai-agent dev harness as a fallback. localStorage matches where Amplify
+// keeps the Cognito session today.
const AGENT_TOKEN_KEY = 'agentToken'
+const AGENT_SESSION_KEY = 'agentSession'
+
+export type AgentSession = {
+ refresh_token: string
+ expires_at: number
+ client_id: string
+}
export const getAgentToken = (): string | null => window.localStorage.getItem(AGENT_TOKEN_KEY)
@@ -20,6 +27,20 @@ export function setAgentToken(token: string | null): void {
else window.localStorage.removeItem(AGENT_TOKEN_KEY)
}
+export function getAgentSession(): AgentSession | null {
+ try {
+ const raw = window.localStorage.getItem(AGENT_SESSION_KEY)
+ return raw ? (JSON.parse(raw) as AgentSession) : null
+ } catch {
+ return null
+ }
+}
+
+export function setAgentSession(session: AgentSession | null): void {
+ if (session) window.localStorage.setItem(AGENT_SESSION_KEY, JSON.stringify(session))
+ else window.localStorage.removeItem(AGENT_SESSION_KEY)
+}
+
/* The agent rejected our credential (401 reauth_required) — sign in again */
export class AgentAuthError extends Error {
constructor() {
diff --git a/frontend/src/services/hydra.ts b/frontend/src/services/hydra.ts
new file mode 100644
index 000000000..a923b71b9
--- /dev/null
+++ b/frontend/src/services/hydra.ts
@@ -0,0 +1,187 @@
+/**
+ * In-app Hydra sign-in for the agent service — OAuth 2.1 authorization code +
+ * PKCE, with self-service Dynamic Client Registration. Mirrors the MCP demo
+ * SPA in the authentication repo (hydra-login-consent/scripts/demo-spa).
+ *
+ * Flow: ensure a DCR client for this origin → full-page redirect to the Hydra
+ * login/consent pages → return to the app root with ?code → exchange for
+ * tokens → store access token + refresh session (services/agent.ts) →
+ * silently refresh before expiry.
+ *
+ * The register/token calls go through the dev vite proxy at /hydra
+ * (same-origin, so no CORS); the login redirect itself is a top-level
+ * navigation to the real issuer. Packaged builds need the app origin on the
+ * OAuth front's CORS allow-list, or the exchange moved to the Electron main
+ * process.
+ */
+import { getAgentSession, getAgentToken, setAgentSession, setAgentToken } from './agent'
+
+export const HYDRA_ISSUER = import.meta.env.VITE_HYDRA_ISSUER_URL || 'https://login.dev.remote.it'
+export const MCP_AUDIENCE = import.meta.env.VITE_MCP_AUDIENCE || 'https://mcp.evan.remote.it/mcp'
+const SCOPE = 'openid offline email device:read device:write device:connect device:execute'
+const LIFESPAN = '30m' // access-token TTL override, verified accepted via DCR
+
+// Same-origin proxy path for fetch calls (vite server.proxy['/hydra'])
+const OAUTH_API = '/hydra'
+
+const CLIENT_KEY = 'agentOauthClient'
+const FLOW_KEY = 'agentOauthFlow'
+
+const b64url = (bytes: ArrayBuffer | Uint8Array): string =>
+ btoa(String.fromCharCode(...new Uint8Array(bytes)))
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=+$/, '')
+
+const randomString = (length: number): string => {
+ const bytes = new Uint8Array(length)
+ crypto.getRandomValues(bytes)
+ return b64url(bytes)
+}
+
+const sha256 = async (value: string): Promise =>
+ b64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)))
+
+// The redirect must land somewhere this SPA is served; the app uses hash
+// routing, so the root URL with a ?code query never collides with a route.
+const redirectUri = (): string => `${window.location.origin}/`
+
+type StoredClient = { client_id: string; key: string }
+
+// One public client per (issuer, origin, scope, audience) — the cache key
+// busts when the requested grant changes, like the demo SPA.
+const clientCacheKey = (): string => `${HYDRA_ISSUER}|${window.location.origin}|${SCOPE}|${MCP_AUDIENCE}`
+
+async function ensureClient(): Promise {
+ try {
+ const cached = JSON.parse(window.localStorage.getItem(CLIENT_KEY) || 'null') as StoredClient | null
+ if (cached?.client_id && cached.key === clientCacheKey()) return cached.client_id
+ } catch {}
+
+ const response = await fetch(`${OAUTH_API}/oauth2/register`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ client_name: 'remote.it desktop agent chat',
+ grant_types: ['authorization_code', 'refresh_token'],
+ response_types: ['code'],
+ redirect_uris: [redirectUri()],
+ scope: SCOPE,
+ token_endpoint_auth_method: 'none',
+ authorization_code_grant_access_token_lifespan: LIFESPAN,
+ refresh_token_grant_access_token_lifespan: LIFESPAN,
+ }),
+ })
+ if (!response.ok) throw new Error(`Agent sign-in registration failed (${response.status}): ${await response.text()}`)
+ const { client_id } = (await response.json()) as { client_id: string }
+ window.localStorage.setItem(CLIENT_KEY, JSON.stringify({ client_id, key: clientCacheKey() }))
+ return client_id
+}
+
+async function tokenRequest(params: Record): Promise<{
+ access_token: string
+ refresh_token?: string
+ expires_in?: number
+}> {
+ const response = await fetch(`${OAUTH_API}/oauth2/token`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams(params),
+ })
+ const text = await response.text()
+ if (!response.ok) throw new Error(`Agent token exchange failed (${response.status}): ${text}`)
+ return JSON.parse(text)
+}
+
+function storeTokens(clientId: string, tokens: { access_token: string; refresh_token?: string; expires_in?: number }) {
+ setAgentToken(tokens.access_token)
+ setAgentSession({
+ refresh_token: tokens.refresh_token || getAgentSession()?.refresh_token || '',
+ expires_at: Date.now() + (tokens.expires_in ?? 0) * 1000,
+ client_id: clientId,
+ })
+}
+
+/* Kick off the sign-in: registers the client if needed, then navigates the
+ whole window to the Hydra login page. The app reloads on return. */
+export async function startAgentSignIn(): Promise {
+ const clientId = await ensureClient()
+ const verifier = randomString(32)
+ const state = randomString(16)
+ window.sessionStorage.setItem(FLOW_KEY, JSON.stringify({ verifier, state, clientId }))
+
+ const auth = new URL(`${HYDRA_ISSUER}/oauth2/auth`)
+ auth.searchParams.set('response_type', 'code')
+ auth.searchParams.set('client_id', clientId)
+ auth.searchParams.set('redirect_uri', redirectUri())
+ auth.searchParams.set('scope', SCOPE)
+ auth.searchParams.set('state', state)
+ auth.searchParams.set('code_challenge', await sha256(verifier))
+ auth.searchParams.set('code_challenge_method', 'S256')
+ // RFC 8707: binds the access token's audience to the MCP resource
+ auth.searchParams.set('resource', MCP_AUDIENCE)
+ window.location.assign(auth.toString())
+}
+
+/* Complete the flow after the redirect back. Call once on app boot; returns
+ null when the URL carries no sign-in response. */
+export async function handleAgentSignInCallback(): Promise<{ ok: boolean; error?: string } | null> {
+ const params = new URLSearchParams(window.location.search)
+ const code = params.get('code')
+ const error = params.get('error')
+ if (!code && !error) return null
+
+ // Strip the single-use code from the URL (keep the hash route) so a
+ // refresh doesn't try to reuse it.
+ window.history.replaceState({}, '', window.location.pathname + window.location.hash)
+
+ if (error) {
+ // A client cached from before a scope/resource change can be rejected at
+ // authorize (e.g. invalid_target); drop it so the next attempt re-registers.
+ window.localStorage.removeItem(CLIENT_KEY)
+ return { ok: false, error: `${error}: ${params.get('error_description') || ''}` }
+ }
+
+ const flow = JSON.parse(window.sessionStorage.getItem(FLOW_KEY) || 'null') as {
+ verifier: string
+ state: string
+ clientId: string
+ } | null
+ window.sessionStorage.removeItem(FLOW_KEY)
+ if (!flow || params.get('state') !== flow.state) return { ok: false, error: 'Sign-in expired — try again.' }
+
+ try {
+ const tokens = await tokenRequest({
+ grant_type: 'authorization_code',
+ code: code!,
+ redirect_uri: redirectUri(),
+ client_id: flow.clientId,
+ code_verifier: flow.verifier,
+ })
+ storeTokens(flow.clientId, tokens)
+ return { ok: true }
+ } catch (err) {
+ return { ok: false, error: (err as Error).message }
+ }
+}
+
+/* Refresh the access token when it is missing or close to expiry. Silent
+ no-op when there is nothing to refresh (e.g. a hand-pasted token). */
+export async function ensureFreshAgentToken(): Promise {
+ const session = getAgentSession()
+ if (!session?.refresh_token) return
+ const fresh = getAgentToken() && Date.now() < session.expires_at - 60_000
+ if (fresh) return
+ try {
+ const tokens = await tokenRequest({
+ grant_type: 'refresh_token',
+ refresh_token: session.refresh_token,
+ client_id: session.client_id,
+ })
+ storeTokens(session.client_id, tokens)
+ } catch {
+ // Refresh chain dead (revoked or reuse-detection) — clear so the UI
+ // falls back to the sign-in prompt on the next 401.
+ setAgentSession(null)
+ }
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 85a8e7afb..0367f8fbd 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -34,6 +34,15 @@ export default defineConfig(({ mode }) => ({
changeOrigin: true,
rewrite: p => p.replace(/^\/agent/, ''),
},
+ // Dev-only: same-origin path to the Hydra OAuth front so the browser's
+ // DCR + token-exchange calls avoid CORS entirely (top-level login
+ // redirects go to the real domain and don't need this). Packaged builds
+ // need the origin CORS-allow-listed or a main-process exchange instead.
+ '/hydra': {
+ target: process.env.VITE_HYDRA_ISSUER_URL || 'https://login.dev.remote.it',
+ changeOrigin: true,
+ rewrite: p => p.replace(/^\/hydra/, ''),
+ },
},
},
type: 'module',
From de03d1a3a570907475a75cfccd0a249bef7fe77e Mon Sep 17 00:00:00 2001
From: thelg4
Date: Wed, 22 Jul 2026 13:34:39 -0400
Subject: [PATCH 004/238] fix(chat): stop Amplify from swallowing the agent
sign-in callback
Amplify's OAuth listener consumes and strips ?code/state on page load for
the Cognito flow; the Hydra callback uses the same params on the same
origin, so the agent exchange never ran and sign-in looped. Capture the
params at module-evaluation time (before Amplify configures), claim them
only when this tab started an agent sign-in, and strip the URL
immediately so neither flow double-handles the code.
---
frontend/src/services/hydra.ts | 37 ++++++++++++++++++++++++----------
1 file changed, 26 insertions(+), 11 deletions(-)
diff --git a/frontend/src/services/hydra.ts b/frontend/src/services/hydra.ts
index a923b71b9..7a4cc9b78 100644
--- a/frontend/src/services/hydra.ts
+++ b/frontend/src/services/hydra.ts
@@ -27,6 +27,22 @@ const OAUTH_API = '/hydra'
const CLIENT_KEY = 'agentOauthClient'
const FLOW_KEY = 'agentOauthFlow'
+// Captured synchronously at module-evaluation time: the app's Cognito side
+// (Amplify, configured with an oauth block) installs a URL listener that
+// consumes and strips ?code/state params for ITS authorization-code flow.
+// Our Hydra callback uses the same param names on the same origin, so we must
+// grab them before Amplify boots — and only claim them when this tab actually
+// started an agent sign-in (flow state present), so a genuine Cognito
+// callback is left untouched.
+const bootParams = new URLSearchParams(window.location.search)
+const isAgentCallback =
+ !!window.sessionStorage.getItem(FLOW_KEY) && (bootParams.has('code') || bootParams.has('error'))
+if (isAgentCallback) {
+ // Strip immediately: hides the single-use code from Amplify's listener and
+ // from any reload. The hash route is preserved.
+ window.history.replaceState({}, '', window.location.pathname + window.location.hash)
+}
+
const b64url = (bytes: ArrayBuffer | Uint8Array): string =>
btoa(String.fromCharCode(...new Uint8Array(bytes)))
.replace(/\+/g, '-')
@@ -123,23 +139,22 @@ export async function startAgentSignIn(): Promise {
window.location.assign(auth.toString())
}
+let callbackConsumed = false
+
/* Complete the flow after the redirect back. Call once on app boot; returns
- null when the URL carries no sign-in response. */
+ null when this page load carries no agent sign-in response. Reads the
+ module-scope capture, not the live URL (already stripped above). */
export async function handleAgentSignInCallback(): Promise<{ ok: boolean; error?: string } | null> {
- const params = new URLSearchParams(window.location.search)
- const code = params.get('code')
- const error = params.get('error')
- if (!code && !error) return null
-
- // Strip the single-use code from the URL (keep the hash route) so a
- // refresh doesn't try to reuse it.
- window.history.replaceState({}, '', window.location.pathname + window.location.hash)
+ if (!isAgentCallback || callbackConsumed) return null
+ callbackConsumed = true
+ const code = bootParams.get('code')
+ const error = bootParams.get('error')
if (error) {
// A client cached from before a scope/resource change can be rejected at
// authorize (e.g. invalid_target); drop it so the next attempt re-registers.
window.localStorage.removeItem(CLIENT_KEY)
- return { ok: false, error: `${error}: ${params.get('error_description') || ''}` }
+ return { ok: false, error: `${error}: ${bootParams.get('error_description') || ''}` }
}
const flow = JSON.parse(window.sessionStorage.getItem(FLOW_KEY) || 'null') as {
@@ -148,7 +163,7 @@ export async function handleAgentSignInCallback(): Promise<{ ok: boolean; error?
clientId: string
} | null
window.sessionStorage.removeItem(FLOW_KEY)
- if (!flow || params.get('state') !== flow.state) return { ok: false, error: 'Sign-in expired — try again.' }
+ if (!flow || bootParams.get('state') !== flow.state) return { ok: false, error: 'Sign-in expired — try again.' }
try {
const tokens = await tokenRequest({
From 2f2b69fb5bc93b9f74a7d9983db3598c429e0d3d Mon Sep 17 00:00:00 2001
From: thelg4
Date: Wed, 22 Jul 2026 13:43:19 -0400
Subject: [PATCH 005/238] feat(chat): sign the agent out with the app
App sign-out now clears the stored Hydra access/refresh tokens, resets
the chat transcript, and best-effort revokes the refresh token at Hydra
so the otherwise never-expiring refresh chain dies server-side. Local
credentials are cleared synchronously so a sign-out-triggered reload
cannot race the revoke call.
---
frontend/src/models/auth.ts | 4 ++++
frontend/src/models/chat.ts | 13 ++++++++++++-
frontend/src/services/hydra.ts | 24 ++++++++++++++++++++++++
3 files changed, 40 insertions(+), 1 deletion(-)
diff --git a/frontend/src/models/auth.ts b/frontend/src/models/auth.ts
index 904a2a8e0..385ca9983 100644
--- a/frontend/src/models/auth.ts
+++ b/frontend/src/models/auth.ts
@@ -274,6 +274,10 @@ export default createModel()({
*/
async signedOut(_: void, state) {
await persistor.purge()
+ // Agent (Hydra) session goes with the app session — clears stored
+ // tokens synchronously, revoke is fire-and-forget so sign-out never
+ // blocks on it
+ dispatch.chat.signOut()
// purge has to happen before signOut because signOut can trigger a reload
await state.auth.authService?.signOut()
await dispatch.auth.set({ user: undefined })
diff --git a/frontend/src/models/chat.ts b/frontend/src/models/chat.ts
index a72e1b543..a37a16178 100644
--- a/frontend/src/models/chat.ts
+++ b/frontend/src/models/chat.ts
@@ -10,7 +10,7 @@ import {
AgentHealth,
AgentMessageParam,
} from '../services/agent'
-import { startAgentSignIn, handleAgentSignInCallback, ensureFreshAgentToken } from '../services/hydra'
+import { startAgentSignIn, handleAgentSignInCallback, ensureFreshAgentToken, agentSignOut } from '../services/hydra'
export type ChatToolCall = {
id: string
@@ -182,6 +182,14 @@ export default createModel()({
dispatch.chat.set({ error: null })
await dispatch.chat.checkHealth()
},
+ /* App sign-out tears the agent session down with it: revoke + clear the
+ Hydra credentials and drop the transcript */
+ async signOut() {
+ abortController?.abort()
+ abortController = null
+ dispatch.chat.reset()
+ await agentSignOut()
+ },
}),
reducers: {
set(state: IChatState, params: Partial) {
@@ -211,5 +219,8 @@ export default createModel()({
state.error = null
return state
},
+ reset() {
+ return { ...defaultChatState }
+ },
},
})
diff --git a/frontend/src/services/hydra.ts b/frontend/src/services/hydra.ts
index 7a4cc9b78..14f539dbe 100644
--- a/frontend/src/services/hydra.ts
+++ b/frontend/src/services/hydra.ts
@@ -180,6 +180,30 @@ export async function handleAgentSignInCallback(): Promise<{ ok: boolean; error?
}
}
+/* Sign the agent out alongside the app: drop the local credentials, then
+ best-effort revoke the refresh token so Hydra's otherwise never-expiring
+ refresh chain dies server-side too. The DCR client registration is kept —
+ it belongs to the app origin, not the user. */
+export async function agentSignOut(): Promise {
+ const session = getAgentSession()
+ const token = getAgentToken()
+ setAgentToken(null)
+ setAgentSession(null)
+ const revoke = (value: string, clientId: string) =>
+ fetch(`${OAUTH_API}/oauth2/revoke`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({ token: value, client_id: clientId }),
+ })
+ try {
+ if (session?.refresh_token) await revoke(session.refresh_token, session.client_id)
+ else if (token && session?.client_id) await revoke(token, session.client_id)
+ } catch {
+ // Offline or proxy unavailable — locals are already cleared; the access
+ // token dies at its 30m TTL.
+ }
+}
+
/* Refresh the access token when it is missing or close to expiry. Silent
no-op when there is nothing to refresh (e.g. a hand-pasted token). */
export async function ensureFreshAgentToken(): Promise {
From 080acd14efc55631696defe577b04764ee002e28 Mon Sep 17 00:00:00 2001
From: thelg4
Date: Mon, 27 Jul 2026 13:58:11 -0400
Subject: [PATCH 006/238] docs(chat): design spec for chat panel org selector
Co-Authored-By: Claude Fable 5
---
.../2026-07-27-chat-org-selector-design.md | 106 ++++++++++++++++++
1 file changed, 106 insertions(+)
create mode 100644 docs/superpowers/specs/2026-07-27-chat-org-selector-design.md
diff --git a/docs/superpowers/specs/2026-07-27-chat-org-selector-design.md b/docs/superpowers/specs/2026-07-27-chat-org-selector-design.md
new file mode 100644
index 000000000..fecfda9f4
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-27-chat-org-selector-design.md
@@ -0,0 +1,106 @@
+# Chat Panel Organization Selector — Design
+
+**Date:** 2026-07-27
+**Repos:** `remoteit/desktop` (frontend), `remoteit/ai-agent` (backend)
+**Branch:** `feature/agent-chat-interface`
+
+## Purpose
+
+Let the user pick which organization the AI agent chat is scoped to, and pass
+that org to the ai-agent service so the agent no longer has to resolve org
+context itself (via `whoami` + membership queries) before making org-scoped
+GraphQL/MCP calls.
+
+## Decisions (from brainstorming)
+
+1. **Independent dropdown** in the chat panel, below the header. Defaults to
+ the app's active org (`accounts.activeId`) but can diverge from it. Not
+ persisted across reloads.
+2. **Backend consumes the org via system prompt injection** — no MCP or tool
+ layer changes; the agent stays free to query other orgs when explicitly
+ asked.
+3. **Switching org mid-conversation keeps the chat**; the new org simply
+ applies from the next turn. No transcript divider, no reset.
+4. **Personal account = omit**: when the selection is the user's personal
+ account, the frontend omits the `org` field entirely. The agent's default
+ behavior is already personal-account scope, so nothing needs to be said.
+
+## Frontend (`remoteit/desktop`)
+
+### State — `frontend/src/models/chat.ts`
+
+- Add `orgId: string | null` to `IChatState` (default `null`).
+- When the panel opens (existing `chat.open` effect path): if `orgId` is null
+ or no longer matches the user's id or any membership, set it to
+ `accounts.activeId`.
+- `send()` resolves `orgId` to `{ id, name }`:
+ - Name lookup via `state.organization.accounts[orgId]?.name`, membership via
+ `state.accounts.membership` (same sources as `OrganizationSelect.tsx`).
+ - If `orgId` equals the user's own id (personal account), pass `undefined`.
+- Passes `org` to `streamChat` each turn (service is stateless).
+
+### UI — new `frontend/src/components/Chat/ChatOrgSelect.tsx`
+
+- Compact MUI `Select`, rendered in `ChatPanel.tsx` directly below the header
+ row.
+- Options: "Personal" first (value = user id), then org memberships sorted by
+ name (skip memberships whose org data hasn't loaded, mirroring the
+ `disabled: !org.id` guard in `OrganizationSelect.tsx`).
+- Always enabled, including while streaming — a change only affects the next
+ turn.
+- `onChange` → `dispatch.chat.set({ orgId })`.
+
+### Client — `frontend/src/services/agent.ts`
+
+- `streamChat` options gain `org?: { id: string; name: string }`.
+- Included in the `/api/chat` POST body alongside `conversationId` and
+ `messages` when present.
+
+## Backend (`remoteit/ai-agent`)
+
+### `src/server.ts`
+
+- `parseChatRequest` accepts optional `org`. Validation: if present it must be
+ `{ id: string, name: string }` with non-empty strings — otherwise 400.
+
+### `src/chatService.ts`
+
+- `runChatTurn` gains an optional `org` parameter, threaded to the agent loop
+ / prompt assembly.
+- Audit log entries for the turn include the org id.
+
+### System prompt
+
+- When `org` is present, append a section to the system prompt:
+
+ > ## Selected organization
+ > The user has selected organization "" (accountId ``) in the
+ > app. Use this accountId for org-scoped tools unless the user explicitly
+ > asks about a different organization or their personal account.
+
+- **Sanitization:** the org name is user-influenced data entering the system
+ prompt. Strip newlines/control characters and cap length (~100 chars)
+ before injection. The id is validated as a plausible id string (no
+ whitespace/newlines).
+
+## Error handling
+
+- `org` is optional end-to-end; omitted → behavior identical to today.
+- Malformed `org` → 400 from the server (matches existing body validation
+ style).
+- Frontend never blocks a send on org resolution. The dropdown only offers
+ orgs whose data has loaded, so the name lookup should always succeed; if
+ state is inconsistent anyway (name missing), the frontend omits the `org`
+ field for that turn rather than sending a value the server would reject.
+
+## Testing
+
+- **ai-agent (vitest):**
+ - `/api/chat` accepts a valid `org` and the assembled system prompt
+ contains the org section.
+ - Malformed `org` (wrong types, empty strings) → 400.
+ - Omitted `org` → prompt unchanged from today.
+ - Sanitization: newlines and over-long names are cleaned before injection.
+- **desktop frontend:** `npm run typecheck`; manual verification in the dev
+ panel (select org → agent call carries it; personal → field absent;
+ mid-conversation switch applies next turn).
From 368768a05d7ddab925b8d6deac8d81d958c95c67 Mon Sep 17 00:00:00 2001
From: thelg4
Date: Mon, 27 Jul 2026 14:09:28 -0400
Subject: [PATCH 007/238] docs(chat): implementation plan for chat panel org
selector
Co-Authored-By: Claude Fable 5
---
.../plans/2026-07-27-chat-org-selector.md | 682 ++++++++++++++++++
1 file changed, 682 insertions(+)
create mode 100644 docs/superpowers/plans/2026-07-27-chat-org-selector.md
diff --git a/docs/superpowers/plans/2026-07-27-chat-org-selector.md b/docs/superpowers/plans/2026-07-27-chat-org-selector.md
new file mode 100644
index 000000000..fa3dc62db
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-27-chat-org-selector.md
@@ -0,0 +1,682 @@
+# Chat Panel Org Selector Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** An org dropdown in the AI chat panel whose selection rides on every `/api/chat` request so the ai-agent scopes org queries without hunting for org context.
+
+**Architecture:** Frontend (rematch model + MUI Select in `remoteit/desktop`) sends an optional `org: { id, name }` in the chat body each turn; the ai-agent service validates it, threads it through `runChatTurn` → `runAgentLoop`, and injects a sanitized "Selected organization" section into the system prompt after the cache breakpoint. Personal account = field omitted (today's default behavior).
+
+**Tech Stack:** React + rematch + MUI (desktop frontend), Express + vitest (ai-agent, TypeScript ESM — note `.js` import suffixes).
+
+**Spec:** `docs/superpowers/specs/2026-07-27-chat-org-selector-design.md`
+
+## Global Constraints
+
+- Two repos: `/Users/larrygunteriv/github/remoteit/desktop` (branch `feature/agent-chat-interface`) and `/Users/larrygunteriv/github/remoteit/ai-agent` (create branch `feature/chat-org-scope` off current HEAD, which is `feat/docker-containerization`). NEVER commit to or push `main` in either repo.
+- `org` is optional end-to-end; omitted → behavior byte-identical to today.
+- Org name is user-influenced data entering the system prompt: strip control chars, collapse whitespace, cap at 100 chars. Org id must match `/^[A-Za-z0-9-]{1,64}$/`.
+- The org system block goes AFTER the prompt-cache breakpoint (it changes when the user switches orgs; it must not invalidate the cached prefix).
+- ai-agent uses ESM imports with `.js` suffixes (`import ... from "./systemPrompt.js"`); tests run with `npx vitest run `.
+- Desktop frontend has no unit-test infra; its verification is `npm run typecheck` (run in `frontend/`) plus the manual check in Task 6.
+
+---
+
+### Task 1: `orgSystemSection` helper (ai-agent)
+
+**Files:**
+- Modify: `src/systemPrompt.ts` (append after the `SYSTEM_PROMPT` export)
+- Test: `test/systemPrompt.org.test.ts` (new)
+
+**Interfaces:**
+- Produces: `export type OrgSelection = { id: string; name: string }` and `export function orgSystemSection(org: OrgSelection): string | null` — `null` means "omit the section". Tasks 2–3 import both from `./systemPrompt.js`.
+
+- [ ] **Step 1: Create the ai-agent feature branch**
+
+```bash
+cd /Users/larrygunteriv/github/remoteit/ai-agent
+git status --short # confirm no unrelated staged changes; leave any untracked files alone
+git checkout -b feature/chat-org-scope
+```
+
+- [ ] **Step 2: Write the failing test**
+
+Create `test/systemPrompt.org.test.ts`:
+
+```typescript
+import { describe, expect, it } from "vitest";
+import { orgSystemSection } from "../src/systemPrompt.js";
+
+describe("orgSystemSection", () => {
+ it("renders the section with name and accountId", () => {
+ const s = orgSystemSection({ id: "org-123-abc", name: "Acme Inc" });
+ expect(s).toContain("## Selected organization");
+ expect(s).toContain('organization "Acme Inc"');
+ expect(s).toContain("accountId `org-123-abc`");
+ expect(s).toContain("unless the user explicitly asks");
+ });
+
+ it("strips control characters and collapses whitespace in the name", () => {
+ const s = orgSystemSection({ id: "org-1", name: "Acme\nInc\t\u0000 Corp" });
+ expect(s).toContain('organization "Acme Inc Corp"');
+ expect(s).not.toContain("Acme\nInc");
+ });
+
+ it("caps the name at 100 characters", () => {
+ const s = orgSystemSection({ id: "org-1", name: "x".repeat(500) });
+ expect(s).toContain(`"${"x".repeat(100)}"`);
+ expect(s).not.toContain("x".repeat(101));
+ });
+
+ it("returns null for an id that fails the allowlist", () => {
+ expect(orgSystemSection({ id: "bad id\nwith spaces", name: "Acme" })).toBeNull();
+ expect(orgSystemSection({ id: "", name: "Acme" })).toBeNull();
+ expect(orgSystemSection({ id: "x".repeat(65), name: "Acme" })).toBeNull();
+ });
+
+ it("returns null when the name is empty after sanitization", () => {
+ expect(orgSystemSection({ id: "org-1", name: "\u0000\u0001 \n " })).toBeNull();
+ });
+});
+```
+
+- [ ] **Step 3: Run test to verify it fails**
+
+Run: `npx vitest run test/systemPrompt.org.test.ts`
+Expected: FAIL — `orgSystemSection` is not exported.
+
+- [ ] **Step 4: Implement**
+
+Append to `src/systemPrompt.ts`:
+
+```typescript
+export type OrgSelection = { id: string; name: string };
+
+/**
+ * System section for the org the user selected in the app. The name is
+ * user-influenced data entering the system prompt, so it is sanitized;
+ * returns null (omit the section) if either value doesn't survive.
+ */
+export function orgSystemSection(org: OrgSelection): string | null {
+ const id = org.id.trim();
+ if (!/^[A-Za-z0-9-]{1,64}$/.test(id)) return null;
+ const name = org.name
+ .replace(/[\u0000-\u001f\u007f]+/g, " ")
+ .replace(/\s+/g, " ")
+ .trim()
+ .slice(0, 100);
+ if (!name) return null;
+ return `## Selected organization\n\nThe user has selected organization "${name}" (accountId \`${id}\`) in the app. Use this accountId for org-scoped tools unless the user explicitly asks about a different organization or their personal account.`;
+}
+```
+
+- [ ] **Step 5: Run test to verify it passes**
+
+Run: `npx vitest run test/systemPrompt.org.test.ts`
+Expected: PASS (5 tests).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/systemPrompt.ts test/systemPrompt.org.test.ts
+git commit -m "feat(org): sanitized system-prompt section for the selected org"
+```
+
+---
+
+### Task 2: Org block in the agent loop's system prompt (ai-agent)
+
+**Files:**
+- Modify: `src/agentLoop.ts` (deps interface ~line 87–107, system assembly ~line 149–158)
+- Test: `test/agentLoop.org.test.ts` (new)
+
+**Interfaces:**
+- Consumes: `orgSystemSection`, `OrgSelection` from Task 1.
+- Produces: `AgentLoopDeps` gains `org?: OrgSelection`. Task 3 sets it from `runChatTurn`.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `test/agentLoop.org.test.ts` (fake-anthropic pattern copied from `test/agentLoop.wait.test.ts`, extended to capture the stream params):
+
+```typescript
+import { describe, expect, it } from "vitest";
+import type Anthropic from "@anthropic-ai/sdk";
+import { runAgentLoop } from "../src/agentLoop.js";
+import type { AuditLogger } from "../src/auditLog.js";
+import type { McpConnection } from "../src/mcp/types.js";
+
+type SystemBlock = { type: string; text: string; cache_control?: { type: string } };
+
+/** Fake anthropic that records each stream() call's params and ends the turn. */
+function capturingAnthropic(captured: Array<{ system: SystemBlock[] }>): Anthropic {
+ return {
+ messages: {
+ stream: (params: { system: SystemBlock[] }) => {
+ captured.push(params);
+ return { on: () => {}, finalMessage: async () => ({ stop_reason: "end_turn", content: [] }) };
+ },
+ },
+ } as unknown as Anthropic;
+}
+
+const idleMcp: McpConnection = {
+ listTools: async () => [],
+ callTool: async () => ({ text: "{}", isError: false }),
+ close: async () => {},
+};
+
+const audit = { log: () => {} } as unknown as AuditLogger;
+
+function baseDeps(captured: Array<{ system: SystemBlock[] }>) {
+ return {
+ anthropic: capturingAnthropic(captured),
+ mcp: idleMcp,
+ audit,
+ emit: () => {},
+ classify: () => "read" as const,
+ waitForConfirmation: async () => true,
+ };
+}
+
+const turn = [{ role: "user" as const, content: "list my devices" }];
+
+describe("org scope in the system prompt", () => {
+ it("appends the org section after the cache breakpoint", async () => {
+ const captured: Array<{ system: SystemBlock[] }> = [];
+ await runAgentLoop(
+ { ...baseDeps(captured), org: { id: "org-123", name: "Acme Inc" } },
+ "conv-org",
+ turn,
+ );
+ const system = captured[0].system;
+ const last = system[system.length - 1];
+ expect(last.text).toContain("accountId `org-123`");
+ expect(last.cache_control).toBeUndefined();
+ expect(system[system.length - 2].cache_control).toEqual({ type: "ephemeral" });
+ });
+
+ it("without org, the last system block carries the cache breakpoint", async () => {
+ const captured: Array<{ system: SystemBlock[] }> = [];
+ await runAgentLoop(baseDeps(captured), "conv-no-org", turn);
+ const system = captured[0].system;
+ expect(system[system.length - 1].cache_control).toEqual({ type: "ephemeral" });
+ expect(system.some((b) => b.text.includes("## Selected organization"))).toBe(false);
+ });
+
+ it("drops an org that fails sanitization instead of injecting it", async () => {
+ const captured: Array<{ system: SystemBlock[] }> = [];
+ await runAgentLoop(
+ { ...baseDeps(captured), org: { id: "bad id", name: "Acme" } },
+ "conv-bad-org",
+ turn,
+ );
+ const system = captured[0].system;
+ expect(system.some((b) => b.text.includes("## Selected organization"))).toBe(false);
+ expect(system[system.length - 1].cache_control).toEqual({ type: "ephemeral" });
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run test/agentLoop.org.test.ts`
+Expected: FAIL — TypeScript rejects the unknown `org` dep / the first assertion finds no org text.
+
+- [ ] **Step 3: Implement**
+
+In `src/agentLoop.ts`:
+
+1. Add to the imports from `./systemPrompt.js`: `orgSystemSection` and `type OrgSelection` (the file already imports `SYSTEM_PROMPT` from there).
+2. Add to `AgentLoopDeps` (after `extraSystem`):
+
+```typescript
+ /** Org the user selected in the app; injected as a system section. */
+ org?: OrgSelection;
+```
+
+3. Replace the system-assembly block (currently ends with `system[system.length - 1].cache_control = { type: "ephemeral" };`) with:
+
+```typescript
+ const system: TextBlockParam[] = [{ type: "text", text: SYSTEM_PROMPT }];
+ if (deps.extraSystem) {
+ system.push({
+ type: "text",
+ text: `## remote.it query cookbook (published by the MCP server)\n\n${deps.extraSystem}`,
+ });
+ }
+ system[system.length - 1].cache_control = { type: "ephemeral" };
+ // The org block rides after the cache breakpoint: it is tiny and changes
+ // when the user switches orgs, so it must not invalidate the cached prefix.
+ const orgSection = deps.org && orgSystemSection(deps.org);
+ if (orgSection) system.push({ type: "text", text: orgSection });
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `npx vitest run test/agentLoop.org.test.ts`
+Expected: PASS (3 tests).
+
+- [ ] **Step 5: Run the full suite and typecheck**
+
+Run: `npx vitest run && npm run typecheck`
+Expected: all existing tests still PASS; tsc clean.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/agentLoop.ts test/agentLoop.org.test.ts
+git commit -m "feat(org): inject selected-org section into the agent system prompt"
+```
+
+---
+
+### Task 3: Wire protocol — request validation and threading (ai-agent)
+
+**Files:**
+- Modify: `src/server.ts` (`parseChatRequest` ~line 24–33, `/api/chat` route ~line 136)
+- Modify: `src/chatService.ts` (`runChatTurn` signature ~line 48, `runAgentLoop` deps ~line 83–99)
+- Test: `test/server.org.test.ts` (new)
+
+**Interfaces:**
+- Consumes: `OrgSelection` from Task 1, `AgentLoopDeps.org` from Task 2.
+- Produces:
+ - `parseChatRequest` is now exported; returns `{ conversationId: string; messages: MessageParam[]; org?: OrgSelection } | { error: string }`.
+ - `runChatTurn(services, ctx, conversationId, messages, emit, signal?, org?)` — new trailing optional `org?: OrgSelection`. The GraphQL transport (`src/graphql/schema.ts`) is deliberately NOT changed; it simply never passes `org`.
+ - Task 4's request body: `{ conversationId, messages, org? }`.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `test/server.org.test.ts` (boot helper copied from `test/server.auth.test.ts`; env mode so no bearer is needed — malformed bodies are rejected before any turn machinery runs):
+
+```typescript
+import { afterAll, describe, expect, it } from "vitest";
+import type { AddressInfo } from "node:net";
+import type http from "node:http";
+import { createServer, parseChatRequest } from "../src/server.js";
+import type { ChatServices } from "../src/chatService.js";
+
+function makeServices(): ChatServices {
+ return {
+ config: { toolClassificationOverrides: {} } as ChatServices["config"],
+ anthropic: {} as ChatServices["anthropic"],
+ audit: { log: () => {}, withUser: () => ({ log: () => {} }) } as unknown as ChatServices["audit"],
+ tokenProvider: { getMcpAuth: async () => ({ token: "t", sub: "s", email: "e" }) } as unknown as ChatServices["tokenProvider"],
+ connectMcp: async () => ({
+ listTools: async () => [],
+ callTool: async () => ({ text: "", isError: false }),
+ close: async () => {},
+ }),
+ };
+}
+
+const servers: http.Server[] = [];
+
+async function boot(): Promise {
+ const { httpServer } = createServer(makeServices(), { mode: "env" });
+ await new Promise((resolve) => httpServer.listen(0, resolve));
+ servers.push(httpServer);
+ const { port } = httpServer.address() as AddressInfo;
+ return `http://127.0.0.1:${port}`;
+}
+
+afterAll(async () => {
+ await Promise.all(servers.map((s) => new Promise((resolve) => s.close(resolve))));
+});
+
+const validTurn = { conversationId: "c1", messages: [{ role: "user", content: "hi" }] };
+
+describe("POST /api/chat org validation", () => {
+ it.each([
+ ["non-object org", { ...validTurn, org: "acme" }],
+ ["missing name", { ...validTurn, org: { id: "org-1" } }],
+ ["empty id", { ...validTurn, org: { id: "", name: "Acme" } }],
+ ["whitespace name", { ...validTurn, org: { id: "org-1", name: " " } }],
+ ["non-string id", { ...validTurn, org: { id: 42, name: "Acme" } }],
+ ])("400s on %s", async (_label, body) => {
+ const base = await boot();
+ const res = await fetch(`${base}/api/chat`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ expect(res.status).toBe(400);
+ });
+});
+
+describe("parseChatRequest org passthrough", () => {
+ it("accepts a valid org", () => {
+ const parsed = parseChatRequest({ ...validTurn, org: { id: "org-1", name: "Acme" } });
+ expect(parsed).toMatchObject({ conversationId: "c1", org: { id: "org-1", name: "Acme" } });
+ });
+
+ it("accepts an omitted org", () => {
+ const parsed = parseChatRequest(validTurn);
+ expect("error" in parsed).toBe(false);
+ expect((parsed as { org?: unknown }).org).toBeUndefined();
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `npx vitest run test/server.org.test.ts`
+Expected: FAIL — `parseChatRequest` is not exported; malformed-org bodies currently return 200 (SSE), not 400.
+
+- [ ] **Step 3: Implement**
+
+In `src/server.ts`:
+
+1. Import the type: `import type { OrgSelection } from "./systemPrompt.js";`
+2. Replace `parseChatRequest` with (note the added `export`):
+
+```typescript
+/** Validate the /api/chat body, returning a typed turn or an error message. */
+export function parseChatRequest(
+ body: unknown,
+): { conversationId: string; messages: MessageParam[]; org?: OrgSelection } | { error: string } {
+ const b = body as { conversationId?: unknown; messages?: unknown; org?: unknown };
+ if (typeof b.conversationId !== "string" || !Array.isArray(b.messages) || b.messages.length === 0) {
+ return { error: "Body must be { conversationId: string, messages: MessageParam[] }" };
+ }
+ let org: OrgSelection | undefined;
+ if (b.org !== undefined) {
+ const o = b.org as { id?: unknown; name?: unknown };
+ if (
+ typeof b.org !== "object" ||
+ b.org === null ||
+ typeof o.id !== "string" ||
+ !o.id.trim() ||
+ typeof o.name !== "string" ||
+ !o.name.trim()
+ ) {
+ return { error: "org must be { id: string, name: string } with non-empty values" };
+ }
+ org = { id: o.id, name: o.name };
+ }
+ return { conversationId: b.conversationId, messages: b.messages as MessageParam[], org };
+}
+```
+
+3. Pass it through in the `/api/chat` route:
+
+```typescript
+ await runChatTurn(services, restContext(req), parsed.conversationId, parsed.messages, emit, controller.signal, parsed.org);
+```
+
+In `src/chatService.ts`:
+
+1. Import the type: `import type { OrgSelection } from "./systemPrompt.js";`
+2. Add the trailing parameter to `runChatTurn`:
+
+```typescript
+export async function runChatTurn(
+ services: ChatServices,
+ ctx: TokenRequestContext,
+ conversationId: string,
+ messages: MessageParam[],
+ emit: AgentEmitter,
+ signal?: AbortSignal,
+ org?: OrgSelection,
+): Promise {
+```
+
+3. Inside the tools branch, after `const turnAudit = audit.withUser({ sub, email });`, add the audit entry:
+
+```typescript
+ if (org) turnAudit.log({ event: "org_scope", conversationId, detail: org.id });
+```
+
+4. Add `org` to the `runAgentLoop` deps object (next to `extraSystem`):
+
+```typescript
+ extraSystem: cookbook ?? undefined,
+ org,
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `npx vitest run test/server.org.test.ts`
+Expected: PASS (7 tests).
+
+- [ ] **Step 5: Run the full suite and typecheck**
+
+Run: `npx vitest run && npm run typecheck`
+Expected: all PASS; tsc clean (the unchanged GraphQL call site is fine — `org` is optional).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/server.ts src/chatService.ts test/server.org.test.ts
+git commit -m "feat(org): accept and thread the selected org through /api/chat"
+```
+
+---
+
+### Task 4: Frontend client — send `org` on the wire (desktop)
+
+**Files:**
+- Modify: `frontend/src/services/agent.ts` (types ~line 58–67, `streamChat` ~line 69–81)
+
+**Interfaces:**
+- Consumes: the Task 3 body shape `{ conversationId, messages, org? }`.
+- Produces: `export type OrgSelection = { id: string; name: string }` and `streamChat` options gain `org?: OrgSelection`. Task 5 imports `OrgSelection` from `../services/agent`.
+
+- [ ] **Step 1: Implement**
+
+In `frontend/src/services/agent.ts`, add the type next to `AgentMessageParam`:
+
+```typescript
+export type OrgSelection = { id: string; name: string }
+```
+
+Extend `streamChat`'s options and body (only the changed lines shown):
+
+```typescript
+export async function streamChat(options: {
+ conversationId: string
+ messages: AgentMessageParam[]
+ org?: OrgSelection
+ signal?: AbortSignal
+ onEvent: (event: AgentEvent) => void
+}): Promise {
+ const { conversationId, messages, org, signal, onEvent } = options
+ const response = await fetch(`${AGENT_URL}/api/chat`, {
+ method: 'POST',
+ headers: agentHeaders(),
+ body: JSON.stringify(org ? { conversationId, messages, org } : { conversationId, messages }),
+ signal,
+ })
+```
+
+- [ ] **Step 2: Typecheck**
+
+Run: `cd /Users/larrygunteriv/github/remoteit/desktop/frontend && npm run typecheck`
+Expected: clean (no errors introduced; callers pass `org` as optional).
+
+- [ ] **Step 3: Commit**
+
+```bash
+cd /Users/larrygunteriv/github/remoteit/desktop
+git add frontend/src/services/agent.ts
+git commit -m "feat(chat): optional org field on the agent chat request"
+```
+
+---
+
+### Task 5: Chat model — org state, defaulting, and send integration (desktop)
+
+**Files:**
+- Modify: `frontend/src/models/chat.ts` (state ~line 27–47, `send` effect ~line 110–134, new `syncOrg` effect)
+
+**Interfaces:**
+- Consumes: `OrgSelection` from Task 4; app state `state.user.id`, `state.accounts.activeId`, `state.accounts.membership` (items have `account.id`), `state.organization.accounts` (lookup by account id, has `.name`) — the same sources `frontend/src/components/OrganizationSelect.tsx` uses.
+- Produces: `IChatState.orgId: string | null`; effect `dispatch.chat.syncOrg()` (Task 6 calls it when the panel opens); `dispatch.chat.set({ orgId })` (Task 6's dropdown calls it).
+
+- [ ] **Step 1: Implement state**
+
+In `frontend/src/models/chat.ts`:
+
+1. Add `OrgSelection` to the imports from `'../services/agent'`.
+2. Add to `IChatState` (after `conversationId`):
+
+```typescript
+ /** Org the agent is scoped to; null = uninitialized, user id = personal */
+ orgId: string | null
+```
+
+3. Add to `defaultChatState`: `orgId: null,`
+
+- [ ] **Step 2: Implement `syncOrg`**
+
+Add to `effects` (after `send`). Runs when the panel opens: adopt the app's active org unless the current selection is still valid, so the chat org defaults to what the user is looking at but can diverge afterward:
+
+```typescript
+ /* Default the chat org to the app's active org when unset or no longer valid */
+ async syncOrg(_: void, state) {
+ const userId = state.user.id
+ const validIds = new Set([userId, ...state.accounts.membership.map(m => m.account.id)])
+ if (!state.chat.orgId || !validIds.has(state.chat.orgId)) {
+ dispatch.chat.set({ orgId: state.accounts.activeId || userId })
+ }
+ },
+```
+
+- [ ] **Step 3: Implement send integration**
+
+In the `send` effect, before the `streamChat` call, resolve the selection (personal account → `undefined`, per spec decision 4; a selection whose org data is missing → `undefined` rather than a value the server would 400 on):
+
+```typescript
+ const orgId = state.chat.orgId
+ let org: OrgSelection | undefined
+ if (orgId && orgId !== state.user.id) {
+ const name = state.organization.accounts[orgId]?.name
+ const isMember = state.accounts.membership.some(m => m.account.id === orgId)
+ if (name && isMember) org = { id: orgId, name }
+ }
+```
+
+and pass it through:
+
+```typescript
+ await streamChat({
+ conversationId,
+ messages,
+ org,
+ signal: abortController.signal,
+ onEvent: event => dispatch.chat.applyEvent(event),
+ })
+```
+
+- [ ] **Step 4: Typecheck**
+
+Run: `cd /Users/larrygunteriv/github/remoteit/desktop/frontend && npm run typecheck`
+Expected: clean.
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /Users/larrygunteriv/github/remoteit/desktop
+git add frontend/src/models/chat.ts
+git commit -m "feat(chat): org selection state scoped to the chat panel"
+```
+
+---
+
+### Task 6: Dropdown UI + panel wiring + manual verification (desktop)
+
+**Files:**
+- Create: `frontend/src/components/Chat/ChatOrgSelect.tsx`
+- Modify: `frontend/src/components/Chat/ChatPanel.tsx` (open effect ~line 23–28, header ~line 53–66)
+
+**Interfaces:**
+- Consumes: `state.chat.orgId`, `dispatch.chat.set({ orgId })`, `dispatch.chat.syncOrg()` from Task 5; membership/org-name state as in Task 5.
+- Produces: ``, rendered directly below the chat header.
+
+- [ ] **Step 1: Create the component**
+
+Create `frontend/src/components/Chat/ChatOrgSelect.tsx`. Mirrors `OrganizationSelect.tsx`'s data sourcing: memberships joined to `organization.accounts` for names, orgs whose data hasn't loaded are skipped, sorted by name; hidden entirely when the user has no orgs.
+
+```tsx
+import React from 'react'
+import { useSelector, useDispatch } from 'react-redux'
+import { Box, TextField, MenuItem } from '@mui/material'
+import { State, Dispatch } from '../../store'
+
+/* Org the agent is scoped to — defaults to the app's active org (models/chat
+ syncOrg) but diverges freely; a change applies from the next turn */
+export const ChatOrgSelect: React.FC = () => {
+ const dispatch = useDispatch()
+ const orgId = useSelector((state: State) => state.chat.orgId)
+ const userId = useSelector((state: State) => state.user.id)
+ const memberships = useSelector((state: State) => state.accounts.membership)
+ const organizations = useSelector((state: State) => state.organization.accounts)
+
+ const options = memberships
+ .map(m => ({ id: m.account.id, name: organizations[m.account.id]?.name || '' }))
+ .filter(o => o.name)
+ .sort((a, b) => a.name.localeCompare(b.name))
+
+ if (!options.length) return null
+
+ return (
+
+ dispatch.chat.set({ orgId: event.target.value })}
+ >
+
+ {options.map(o => (
+
+ ))}
+
+
+ )
+}
+```
+
+- [ ] **Step 2: Wire into the panel**
+
+In `frontend/src/components/Chat/ChatPanel.tsx`:
+
+1. Import: `import { ChatOrgSelect } from './ChatOrgSelect'`
+2. Add `dispatch.chat.syncOrg()` to the open effect:
+
+```typescript
+ useEffect(() => {
+ if (chat.open) {
+ dispatch.chat.resetTransient()
+ dispatch.chat.syncOrg()
+ dispatch.chat.checkHealth()
+ }
+ }, [chat.open])
+```
+
+3. Render `` immediately after the header `` (the one closing at line 66), before the health notices.
+
+- [ ] **Step 3: Typecheck**
+
+Run: `cd /Users/larrygunteriv/github/remoteit/desktop/frontend && npm run typecheck`
+Expected: clean.
+
+- [ ] **Step 4: Manual verification**
+
+1. Start the agent service: `cd /Users/larrygunteriv/github/remoteit/ai-agent && npm run dev` (port 3001).
+2. Start the frontend: `cd /Users/larrygunteriv/github/remoteit/desktop/frontend && npm start` (port 3003), sign in, open the chat panel.
+3. Verify the dropdown sits below the header and defaults to the org the app sidebar has active (or Personal).
+4. With devtools → Network open, send a message with an org selected: the `/api/chat` request body contains `"org":{"id":...,"name":...}`.
+5. Select Personal, send again: the body has no `org` key.
+6. Switch org mid-conversation and send: transcript is kept; the new org appears in the next request body.
+7. Ask the agent to "list this organization's devices": it should use the accountId directly (tool call input shows the selected org id) without a `whoami`/membership lookup first.
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /Users/larrygunteriv/github/remoteit/desktop
+git add frontend/src/components/Chat/ChatOrgSelect.tsx frontend/src/components/Chat/ChatPanel.tsx
+git commit -m "feat(chat): org selector dropdown in the chat panel"
+```
From c70ee3af36963135aafea2a3bc87ca39ff796f6b Mon Sep 17 00:00:00 2001
From: thelg4
Date: Mon, 27 Jul 2026 14:52:37 -0400
Subject: [PATCH 008/238] feat(chat): optional org field on the agent chat
request
---
frontend/src/services/agent.ts | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/frontend/src/services/agent.ts b/frontend/src/services/agent.ts
index bad2572f0..5724fc1d9 100644
--- a/frontend/src/services/agent.ts
+++ b/frontend/src/services/agent.ts
@@ -65,18 +65,21 @@ export type AgentEvent =
export type AgentMessageParam = { role: 'user' | 'assistant'; content: string }
+export type OrgSelection = { id: string; name: string }
+
/* Stream one chat turn. Events arrive as SSE: `event: \ndata: \n\n` */
export async function streamChat(options: {
conversationId: string
messages: AgentMessageParam[]
+ org?: OrgSelection
signal?: AbortSignal
onEvent: (event: AgentEvent) => void
}): Promise {
- const { conversationId, messages, signal, onEvent } = options
+ const { conversationId, messages, org, signal, onEvent } = options
const response = await fetch(`${AGENT_URL}/api/chat`, {
method: 'POST',
headers: agentHeaders(),
- body: JSON.stringify({ conversationId, messages }),
+ body: JSON.stringify(org ? { conversationId, messages, org } : { conversationId, messages }),
signal,
})
if (response.status === 401) throw new AgentAuthError()
From 723e41e99180102de908be3f7282fb2f4b204ed8 Mon Sep 17 00:00:00 2001
From: thelg4
Date: Mon, 27 Jul 2026 15:00:19 -0400
Subject: [PATCH 009/238] feat(chat): org selection state scoped to the chat
panel
---
frontend/src/models/chat.ts | 20 ++++++++++++++++++++
1 file changed, 20 insertions(+)
diff --git a/frontend/src/models/chat.ts b/frontend/src/models/chat.ts
index a37a16178..ae04b4e3e 100644
--- a/frontend/src/models/chat.ts
+++ b/frontend/src/models/chat.ts
@@ -9,6 +9,7 @@ import {
AgentEvent,
AgentHealth,
AgentMessageParam,
+ OrgSelection,
} from '../services/agent'
import { startAgentSignIn, handleAgentSignInCallback, ensureFreshAgentToken, agentSignOut } from '../services/hydra'
@@ -29,6 +30,8 @@ export type IChatState = {
expanded: boolean
messages: ChatTranscriptMessage[]
conversationId: string
+ /** Org the agent is scoped to; null = uninitialized, user id = personal */
+ orgId: string | null
streaming: boolean
pendingConfirmation: { toolUseId: string; toolName: string; input: Record } | null
error: string | null
@@ -40,6 +43,7 @@ export const defaultChatState: IChatState = {
expanded: false,
messages: [],
conversationId: '',
+ orgId: null,
streaming: false,
pendingConfirmation: null,
error: null,
@@ -114,11 +118,19 @@ export default createModel()({
dispatch.chat.addUserMessage(text)
dispatch.chat.set({ conversationId, streaming: true, error: null })
abortController = new AbortController()
+ const orgId = state.chat.orgId
+ let org: OrgSelection | undefined
+ if (orgId && orgId !== state.user.id) {
+ const name = state.organization.accounts[orgId]?.name
+ const isMember = state.accounts.membership.some(m => m.account.id === orgId)
+ if (name && isMember) org = { id: orgId, name }
+ }
try {
await ensureFreshAgentToken()
await streamChat({
conversationId,
messages,
+ org,
signal: abortController.signal,
onEvent: event => dispatch.chat.applyEvent(event),
})
@@ -132,6 +144,14 @@ export default createModel()({
dispatch.chat.set({ streaming: false })
}
},
+ /* Default the chat org to the app's active org when unset or no longer valid */
+ async syncOrg(_: void, state) {
+ const userId = state.user.id
+ const validIds = new Set([userId, ...state.accounts.membership.map(m => m.account.id)])
+ if (!state.chat.orgId || !validIds.has(state.chat.orgId)) {
+ dispatch.chat.set({ orgId: state.accounts.activeId || userId })
+ }
+ },
async confirm(approved: boolean, state) {
const pending = state.chat.pendingConfirmation
if (!pending) return
From c692c24efaacc543c01be8dc9edc8340db7c4b74 Mon Sep 17 00:00:00 2001
From: thelg4
Date: Mon, 27 Jul 2026 15:03:14 -0400
Subject: [PATCH 010/238] feat(chat): org selector dropdown in the chat panel
---
.../src/components/Chat/ChatOrgSelect.tsx | 41 +++++++++++++++++++
frontend/src/components/Chat/ChatPanel.tsx | 3 ++
2 files changed, 44 insertions(+)
create mode 100644 frontend/src/components/Chat/ChatOrgSelect.tsx
diff --git a/frontend/src/components/Chat/ChatOrgSelect.tsx b/frontend/src/components/Chat/ChatOrgSelect.tsx
new file mode 100644
index 000000000..863761bbc
--- /dev/null
+++ b/frontend/src/components/Chat/ChatOrgSelect.tsx
@@ -0,0 +1,41 @@
+import React from 'react'
+import { useSelector, useDispatch } from 'react-redux'
+import { Box, TextField, MenuItem } from '@mui/material'
+import { State, Dispatch } from '../../store'
+
+/* Org the agent is scoped to — defaults to the app's active org (models/chat
+ syncOrg) but diverges freely; a change applies from the next turn */
+export const ChatOrgSelect: React.FC = () => {
+ const dispatch = useDispatch()
+ const orgId = useSelector((state: State) => state.chat.orgId)
+ const userId = useSelector((state: State) => state.user.id)
+ const memberships = useSelector((state: State) => state.accounts.membership)
+ const organizations = useSelector((state: State) => state.organization.accounts)
+
+ const options = memberships
+ .map(m => ({ id: m.account.id, name: organizations[m.account.id]?.name || '' }))
+ .filter(o => o.name)
+ .sort((a, b) => a.name.localeCompare(b.name))
+
+ if (!options.length) return null
+
+ return (
+
+ dispatch.chat.set({ orgId: event.target.value })}
+ >
+
+ {options.map(o => (
+
+ ))}
+
+
+ )
+}
diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx
index 47f998598..caf8061aa 100644
--- a/frontend/src/components/Chat/ChatPanel.tsx
+++ b/frontend/src/components/Chat/ChatPanel.tsx
@@ -7,6 +7,7 @@ import { IconButton } from '../../buttons/IconButton'
import { ChatMessages } from './ChatMessages'
import { ChatApproval } from './ChatApproval'
import { ChatInput } from './ChatInput'
+import { ChatOrgSelect } from './ChatOrgSelect'
import { Notice } from '../Notice'
export const ChatPanel: React.FC = () => {
@@ -23,6 +24,7 @@ export const ChatPanel: React.FC = () => {
useEffect(() => {
if (chat.open) {
dispatch.chat.resetTransient()
+ dispatch.chat.syncOrg()
dispatch.chat.checkHealth()
}
}, [chat.open])
@@ -64,6 +66,7 @@ export const ChatPanel: React.FC = () => {
dispatch.chat.clearConversation()} />
dispatch.chat.set({ open: false })} />
+
{chat.health === 'unreachable' && (
Agent unreachable — is the dev service running on :3001?
From 63dccb7a22b043ff22420f7ce65ef14ddc451154 Mon Sep 17 00:00:00 2001
From: thelg4
Date: Mon, 27 Jul 2026 15:23:43 -0400
Subject: [PATCH 011/238] =?UTF-8?q?fix(chat):=20final=20review=20=E2=80=94?=
=?UTF-8?q?=20membership=20name=20fallback,=20no=20orgId=20persistence?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fall back to membership.name in ChatOrgSelect and chat.ts send() when
organization.accounts is unloaded, so org scope never silently
degrades to personal; guard against a whitespace-only name so the
client never sends one the server's non-empty-after-trim validation
would reject. Force-adopt the app's active org on the first syncOrg
after load so orgId (redux-persisted) doesn't survive page reloads,
per the "not persisted" spec decision, while preserving intra-session
divergence across panel close/open.
---
.../src/components/Chat/ChatOrgSelect.tsx | 2 +-
frontend/src/models/chat.ts | 21 +++++++++++++++++--
2 files changed, 20 insertions(+), 3 deletions(-)
diff --git a/frontend/src/components/Chat/ChatOrgSelect.tsx b/frontend/src/components/Chat/ChatOrgSelect.tsx
index 863761bbc..6152d2b56 100644
--- a/frontend/src/components/Chat/ChatOrgSelect.tsx
+++ b/frontend/src/components/Chat/ChatOrgSelect.tsx
@@ -13,7 +13,7 @@ export const ChatOrgSelect: React.FC = () => {
const organizations = useSelector((state: State) => state.organization.accounts)
const options = memberships
- .map(m => ({ id: m.account.id, name: organizations[m.account.id]?.name || '' }))
+ .map(m => ({ id: m.account.id, name: (organizations[m.account.id]?.name || m.name || '').trim() }))
.filter(o => o.name)
.sort((a, b) => a.name.localeCompare(b.name))
diff --git a/frontend/src/models/chat.ts b/frontend/src/models/chat.ts
index ae04b4e3e..dde57d85c 100644
--- a/frontend/src/models/chat.ts
+++ b/frontend/src/models/chat.ts
@@ -107,6 +107,11 @@ function toMessageParams(messages: ChatTranscriptMessage[]): AgentMessageParam[]
}
let abortController: AbortController | null = null
+// orgId is redux-persisted but must not survive a reload (spec: "not
+// persisted"); on the first syncOrg after load, force-adopt the app's active
+// org regardless of what was rehydrated. After that, intra-session
+// divergence (the user picking a different org in the panel) is left alone.
+let orgSynced = false
export default createModel()({
state: { ...defaultChatState },
@@ -121,7 +126,12 @@ export default createModel()({
const orgId = state.chat.orgId
let org: OrgSelection | undefined
if (orgId && orgId !== state.user.id) {
- const name = state.organization.accounts[orgId]?.name
+ // organization.accounts can be unloaded while the membership is
+ // present; fall back to the name carried on the membership itself so
+ // org scope never silently degrades to personal.
+ const name = (
+ state.organization.accounts[orgId]?.name || state.accounts.membership.find(m => m.account.id === orgId)?.name || ''
+ ).trim()
const isMember = state.accounts.membership.some(m => m.account.id === orgId)
if (name && isMember) org = { id: orgId, name }
}
@@ -144,9 +154,16 @@ export default createModel()({
dispatch.chat.set({ streaming: false })
}
},
- /* Default the chat org to the app's active org when unset or no longer valid */
+ /* Default the chat org to the app's active org when unset or no longer valid.
+ orgId is not persisted across reloads: the first sync after load always
+ adopts the app's active org, discarding whatever was rehydrated. */
async syncOrg(_: void, state) {
const userId = state.user.id
+ if (!orgSynced) {
+ orgSynced = true
+ dispatch.chat.set({ orgId: state.accounts.activeId || userId })
+ return
+ }
const validIds = new Set([userId, ...state.accounts.membership.map(m => m.account.id)])
if (!state.chat.orgId || !validIds.has(state.chat.orgId)) {
dispatch.chat.set({ orgId: state.accounts.activeId || userId })
From 5f77b2a16801c6a3900cc3ce01fae9eb8be6eb49 Mon Sep 17 00:00:00 2001
From: thelg4
Date: Mon, 27 Jul 2026 15:59:42 -0400
Subject: [PATCH 012/238] style(chat): taller org selector and themed
scrollbars
Co-Authored-By: Claude Fable 5
---
.../src/components/Chat/ChatMessageItem.tsx | 100 +++++++++---------
frontend/src/components/Chat/ChatMessages.tsx | 3 +-
.../src/components/Chat/ChatOrgSelect.tsx | 1 +
frontend/src/components/Chat/chatScrollbar.ts | 16 +++
4 files changed, 70 insertions(+), 50 deletions(-)
create mode 100644 frontend/src/components/Chat/chatScrollbar.ts
diff --git a/frontend/src/components/Chat/ChatMessageItem.tsx b/frontend/src/components/Chat/ChatMessageItem.tsx
index 02a510244..acc1ae787 100644
--- a/frontend/src/components/Chat/ChatMessageItem.tsx
+++ b/frontend/src/components/Chat/ChatMessageItem.tsx
@@ -4,14 +4,13 @@ import remarkGfm from 'remark-gfm'
import { Box, Typography } from '@mui/material'
import { ChatTranscriptMessage } from '../../models/chat'
import { ChatToolCalls } from './ChatToolCalls'
+import { scrollbarStyles } from './chatScrollbar'
export const ChatMessageItem: React.FC<{ message: ChatTranscriptMessage }> = ({ message }) => {
if (message.role === 'user')
return (
-
+
{message.text}
@@ -23,53 +22,56 @@ export const ChatMessageItem: React.FC<{ message: ChatTranscriptMessage }> = ({
({ '& pre, & table': scrollbarStyles(theme) }),
+ {
+ fontSize: 14,
+ lineHeight: 1.5,
+ wordBreak: 'break-word',
+ '& p': { marginY: 0.75 },
+ '& ul, & ol': { paddingLeft: 3, marginY: 0.5 },
+ '& li': { marginY: 0.25 },
+ '& h1, & h2, & h3, & h4': { fontSize: 15, marginTop: 1.5, marginBottom: 0.5 },
+ '& a': { color: 'primary.main' },
+ '& code': {
+ fontFamily: "'Roboto Mono', monospace",
+ fontSize: 12,
+ bgcolor: 'grayLightest.main',
+ borderRadius: 1,
+ paddingX: 0.5,
+ paddingY: 0.25,
+ },
+ '& pre': {
+ overflowX: 'auto',
+ bgcolor: 'grayLightest.main',
+ borderRadius: 2,
+ padding: 1.5,
+ '& code': { padding: 0, bgcolor: 'transparent' },
+ },
+ '& table': {
+ display: 'block',
+ overflowX: 'auto',
+ borderCollapse: 'collapse',
+ fontSize: 12,
+ marginY: 1,
+ },
+ '& th, & td': {
+ border: '1px solid',
+ borderColor: 'grayLighter.main',
+ paddingX: 1,
+ paddingY: 0.5,
+ textAlign: 'left',
+ whiteSpace: 'nowrap',
+ },
+ '& blockquote': {
+ borderLeft: '3px solid',
+ borderColor: 'grayLighter.main',
+ marginX: 0,
+ paddingLeft: 1.5,
+ color: 'grayDark.main',
+ },
},
- '& pre': {
- overflowX: 'auto',
- bgcolor: 'grayLightest.main',
- borderRadius: 2,
- padding: 1.5,
- '& code': { padding: 0, bgcolor: 'transparent' },
- },
- '& table': {
- display: 'block',
- overflowX: 'auto',
- borderCollapse: 'collapse',
- fontSize: 12,
- marginY: 1,
- },
- '& th, & td': {
- border: '1px solid',
- borderColor: 'grayLighter.main',
- paddingX: 1,
- paddingY: 0.5,
- textAlign: 'left',
- whiteSpace: 'nowrap',
- },
- '& blockquote': {
- borderLeft: '3px solid',
- borderColor: 'grayLighter.main',
- marginX: 0,
- paddingLeft: 1.5,
- color: 'grayDark.main',
- },
- }}
+ ]}
>
{message.text}
diff --git a/frontend/src/components/Chat/ChatMessages.tsx b/frontend/src/components/Chat/ChatMessages.tsx
index adb48fce5..33feaef1f 100644
--- a/frontend/src/components/Chat/ChatMessages.tsx
+++ b/frontend/src/components/Chat/ChatMessages.tsx
@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react'
import { Box } from '@mui/material'
import { ChatTranscriptMessage } from '../../models/chat'
import { ChatMessageItem } from './ChatMessageItem'
+import { scrollbarStyles } from './chatScrollbar'
type Props = {
messages: ChatTranscriptMessage[]
@@ -25,7 +26,7 @@ export const ChatMessages: React.FC = ({ messages, streaming, children })
const el = ref.current
if (el) setPinned(el.scrollHeight - el.scrollTop - el.clientHeight < 40)
}}
- sx={{ flexGrow: 1, overflowY: 'auto', paddingX: 2 }}
+ sx={[{ flexGrow: 1, overflowY: 'auto', paddingX: 2 }, scrollbarStyles]}
>
{messages.map((message, index) => (
diff --git a/frontend/src/components/Chat/ChatOrgSelect.tsx b/frontend/src/components/Chat/ChatOrgSelect.tsx
index 6152d2b56..0181a8dd1 100644
--- a/frontend/src/components/Chat/ChatOrgSelect.tsx
+++ b/frontend/src/components/Chat/ChatOrgSelect.tsx
@@ -28,6 +28,7 @@ export const ChatOrgSelect: React.FC = () => {
label="Organization"
value={orgId || userId}
onChange={event => dispatch.chat.set({ orgId: event.target.value })}
+ sx={{ '& .MuiSelect-select': { paddingY: '12px' } }}
>
{options.map(o => (
diff --git a/frontend/src/components/Chat/chatScrollbar.ts b/frontend/src/components/Chat/chatScrollbar.ts
new file mode 100644
index 000000000..dec8fd065
--- /dev/null
+++ b/frontend/src/components/Chat/chatScrollbar.ts
@@ -0,0 +1,16 @@
+import { Theme } from '@mui/material/styles'
+
+/* Slim, theme-matched scrollbars for the chat panel's scroll surfaces,
+ replacing the default browser bars */
+export const scrollbarStyles = (theme: Theme) => ({
+ scrollbarWidth: 'thin' as const, // Firefox
+ scrollbarColor: `${theme.palette.grayLight.main} transparent`, // Firefox
+ '&::-webkit-scrollbar': { width: 8, height: 8, WebkitAppearance: 'none' as const },
+ '&::-webkit-scrollbar-track': { background: 'transparent' },
+ '&::-webkit-scrollbar-thumb': {
+ borderRadius: 4,
+ backgroundColor: theme.palette.grayLight.main,
+ '&:hover': { backgroundColor: theme.palette.gray.main },
+ },
+ '&::-webkit-scrollbar-corner': { background: 'transparent' },
+})
From 0326821c3e8e3a699e0629144664e31a833e94ed Mon Sep 17 00:00:00 2001
From: thelg4
Date: Mon, 27 Jul 2026 17:06:04 -0400
Subject: [PATCH 013/238] chore(chat): bind agent sign-in to the beta MCP
audience
Matches the ai-agent service's move off mcp.evan.remote.it (older
build without org-scoped list_scripts). The client cache key includes
the audience, so a fresh sign-in re-registers automatically.
Co-Authored-By: Claude Fable 5
---
frontend/src/services/hydra.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/frontend/src/services/hydra.ts b/frontend/src/services/hydra.ts
index 14f539dbe..4fae19cc2 100644
--- a/frontend/src/services/hydra.ts
+++ b/frontend/src/services/hydra.ts
@@ -17,7 +17,7 @@
import { getAgentSession, getAgentToken, setAgentSession, setAgentToken } from './agent'
export const HYDRA_ISSUER = import.meta.env.VITE_HYDRA_ISSUER_URL || 'https://login.dev.remote.it'
-export const MCP_AUDIENCE = import.meta.env.VITE_MCP_AUDIENCE || 'https://mcp.evan.remote.it/mcp'
+export const MCP_AUDIENCE = import.meta.env.VITE_MCP_AUDIENCE || 'https://mcp.beta.remote.it/mcp'
const SCOPE = 'openid offline email device:read device:write device:connect device:execute'
const LIFESPAN = '30m' // access-token TTL override, verified accepted via DCR
From 5167a16962f256530f84cf878fd164f3cdd36b9e Mon Sep 17 00:00:00 2001
From: thelg4
Date: Tue, 28 Jul 2026 11:14:55 -0400
Subject: [PATCH 014/238] docs(chat): design spec for chat popout window
Co-Authored-By: Claude Fable 5
---
.../2026-07-28-chat-popout-window-design.md | 126 ++++++++++++++++++
1 file changed, 126 insertions(+)
create mode 100644 docs/superpowers/specs/2026-07-28-chat-popout-window-design.md
diff --git a/docs/superpowers/specs/2026-07-28-chat-popout-window-design.md b/docs/superpowers/specs/2026-07-28-chat-popout-window-design.md
new file mode 100644
index 000000000..bd4de0899
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-28-chat-popout-window-design.md
@@ -0,0 +1,126 @@
+# Chat Panel Popout Window — Design
+
+**Date:** 2026-07-28
+**Repo:** `remoteit/desktop`
+**Branch:** `feature/agent-chat-interface`
+
+## Purpose
+
+Let the user pop the AI chat panel out of the app into its own window (per
+mockup: pop-out button in the docked chat header; standalone chat window
+with a pop-in button), and bring it back with the conversation intact.
+
+## Decisions (from brainstorming)
+
+1. **Move semantics** — popping out hides the docked panel; the standalone
+ window owns the conversation. Popping back in (button or window close)
+ returns it, transcript intact. No live mirroring between windows.
+2. **Environments: browser + Electron.** Mobile never shows the button.
+ The whole feature remains behind the existing `MODE === 'development'`
+ gate, matching the chat panel itself.
+3. **Mechanism: boot flag + BroadcastChannel.** The popout loads the same
+ app bundle with a `?chatPopout` boot flag; a BroadcastChannel performs
+ the conversation hand-off. No second Vite entry; no reliance on
+ redux-persist write ordering (both windows persist to the same
+ localStorage key, so storage alone is racy).
+
+## Architecture
+
+### Boot flag and rendering
+
+- The `chatPopout` query param is captured at module scope on boot (same
+ pattern as the hydra `?code` capture in `services/hydra.ts`), so hash
+ routing cannot clobber it.
+- `App.tsx`: when the flag is set (and `MODE === 'development'`), render a
+ bare `` in place of the app shell (sidebar/router). All
+ pre-auth gates (loading, sign-in) behave as today; in practice the
+ popout is already authenticated because Amplify and agent tokens live in
+ shared localStorage.
+- Component split: the chat internals (health notices, org select,
+ messages, approval, input) are extracted from `ChatPanel` into a shared
+ piece. `ChatPanel` (docked column) and `ChatWindow` (full-page popout)
+ both render it:
+ - `ChatPanel` header: expand, new chat, **pop out** (new), close.
+ - `ChatWindow` header: new chat, **pop in**. The window's own chrome
+ provides close. No expand button, no panel-close button.
+ - `ChatWindow` ignores `chat.open` (it always shows).
+
+### Popout service — `frontend/src/services/chatPopout.ts`
+
+Owns `window.open`, the BroadcastChannel (`remoteit-chat-popout`), and the
+hand-off protocol. Message types:
+
+| Message | Direction | Payload | Effect |
+|---|---|---|---|
+| `hello` | popout → main | — | main replies `adopt`, then sets `open: false, poppedOut: true` |
+| `adopt` | main → popout | `{ messages, conversationId, orgId }` | popout replaces its chat slice with the payload |
+| `handback` | popout → main | `{ messages, conversationId, orgId }` | main applies payload, sets `poppedOut: false, open: true` |
+| `ping` | main → popout | — | presence check on main boot |
+| `alive` | popout → main | — | main keeps dock hidden (`poppedOut: true`) |
+| `signout` | main → popout | — | popout closes itself WITHOUT sending `handback` (sign-out clears the transcript anyway) |
+
+- `openChatPopout()`: `window.open(origin + '/?chatPopout', 'remoteit-chat',
+ 'popup,width=520,height=780')`.
+- Popout boot: send `hello`; if no `adopt` arrives within 300 ms, fall
+ back to the redux-persisted transcript (covers popout refresh / main
+ gone). A late `adopt` after the fallback is still applied — main's copy
+ is authoritative at hand-off.
+- Pop-in or `beforeunload`: abort any active stream first (same path as
+ the Stop button; the open message is marked interrupted), then send
+ `handback`, then close.
+- Crash resilience: while `poppedOut`, main polls `popoutWindow.closed`
+ (~2s). Closed without a `handback` → restore `open: true` from the
+ persisted transcript. Poll and `handback` are idempotent together.
+- Main boot: `ping`; only an `alive` reply keeps `poppedOut: true`
+ (corrects stale persisted state).
+- App sign-out (`chat.signOut`): broadcast `signout` before clearing.
+
+### Model — `frontend/src/models/chat.ts`
+
+- `poppedOut: boolean` added to `IChatState` (default false; value is
+ authoritative only after the boot ping settles).
+- Effects for the protocol reactions (adopt/handback application) so all
+ state changes stay in the model; the service holds no state of its own
+ beyond the channel and window handle.
+
+### Electron — `electron/src/ElectronApp.ts`
+
+`setWindowOpenHandler` gains one branch: a URL on the app's own origin
+carrying the `chatPopout` flag returns
+
+```
+{ action: 'allow', overrideBrowserWindowOptions:
+ { width: 520, height: 780, minWidth: 360, minHeight: 500, autoHideMenuBar: true } }
+```
+
+All other URLs keep the existing deny + `shell.openExternal` behavior.
+BroadcastChannel works across the two windows unchanged (same origin and
+session partition).
+
+## Edge handling
+
+- **Main window closes/reloads while popped out** — popout keeps working
+ (own store, shared tokens). Next main boot pings; `alive` keeps the dock
+ hidden.
+- **Mid-stream pop-in/close** — stream aborted, message marked
+ interrupted, transcript preserved in the `handback`.
+- **Popout opened twice** — the named window (`'remoteit-chat'`) is
+ reused by `window.open`, so a second click focuses the existing popout.
+- **Mobile / non-dev builds** — button absent (`MODE` gate + no button on
+ mobile via `browser.isMobile`).
+
+## Verification
+
+Typecheck (`cd frontend && npm run typecheck`) plus a manual script:
+
+1. Pop out → docked panel hides, window opens with transcript and org
+ selection intact.
+2. Converse in the popout (org scoping still applies), pop in → dock
+ returns with the full transcript.
+3. Close the popout with the window X → same as pop-in.
+4. Kill the popout process / crash it → dock restores within ~2s.
+5. Reload the main window while popped out → dock stays hidden; popout
+ unaffected.
+6. Sign out of the app → popout closes.
+7. Electron dev build: pop out opens a native window with the specified
+ size; external links still open in the system browser.
From 1d44888ce2ba60c5a074a0199218804dd8e2f458 Mon Sep 17 00:00:00 2001
From: thelg4
Date: Tue, 28 Jul 2026 11:21:19 -0400
Subject: [PATCH 015/238] docs(chat): implementation plan for chat popout
window
Co-Authored-By: Claude Fable 5
---
.../plans/2026-07-28-chat-popout-window.md | 665 ++++++++++++++++++
1 file changed, 665 insertions(+)
create mode 100644 docs/superpowers/plans/2026-07-28-chat-popout-window.md
diff --git a/docs/superpowers/plans/2026-07-28-chat-popout-window.md b/docs/superpowers/plans/2026-07-28-chat-popout-window.md
new file mode 100644
index 000000000..4238e85e8
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-28-chat-popout-window.md
@@ -0,0 +1,665 @@
+# Chat Popout Window Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Pop the chat panel out into its own window (browser + Electron) with move semantics — the docked panel hides while popped out and the conversation hands back intact.
+
+**Architecture:** The popout loads the same app bundle with a `?chatPopout` boot flag; `App.tsx` renders a bare `ChatWindow` instead of the app shell. A BroadcastChannel (`remoteit-chat-popout`) carries the hand-off protocol (hello/adopt/handback/ping/alive/signout) with dependency-injected handlers so the service never imports the store (no circular imports). Electron's `setWindowOpenHandler` gains an allow-branch for the app's own popout URL.
+
+**Tech Stack:** React + rematch + MUI, BroadcastChannel API, Electron BrowserWindow options.
+
+**Spec:** `docs/superpowers/specs/2026-07-28-chat-popout-window-design.md`
+
+## Global Constraints
+
+- Repo `/Users/larrygunteriv/github/remoteit/desktop`, branch `feature/agent-chat-interface`. NEVER commit to or push main.
+- The repo has UNRELATED uncommitted changes (`.npmrc`, `frontend/package.json`, `frontend/src/components/Icon.tsx`) — never touch or stage them.
+- Everything stays behind the existing `MODE === 'development'` gate; mobile (`browser.isMobile`) never shows the pop-out button.
+- Move semantics: popping out hides the docked panel; hand-off payloads travel IN the BroadcastChannel messages, never via storage ordering (persistence is localForage/IndexedDB and both windows write the same key).
+- Channel name `remoteit-chat-popout`; window name `remoteit-chat`; window features `popup=yes,width=520,height=780`; Electron override `{ width: 520, height: 780, minWidth: 360, minHeight: 500, autoHideMenuBar: true }`.
+- Frontend verification is `cd frontend && npm run typecheck` (no unit-test infra). Electron verification is `cd electron && npm run typecheck`.
+- Run `npx prettier --write ` (from `frontend/`) before each frontend commit.
+
+---
+
+### Task 1: Extract `ChatBody` from `ChatPanel`
+
+**Files:**
+- Create: `frontend/src/components/Chat/ChatBody.tsx`
+- Modify: `frontend/src/components/Chat/ChatPanel.tsx`
+
+**Interfaces:**
+- Consumes: existing `ChatOrgSelect`, `ChatMessages`, `ChatApproval`, `ChatInput`, `Notice` components; `state.chat` slice.
+- Produces: `export const ChatBody: React.FC` (no props) — the org select, health notices, message list w/ approval + error, and input. Tasks 2–3 render it from `ChatWindow` and `ChatPanel`.
+
+- [ ] **Step 1: Create ChatBody**
+
+Create `frontend/src/components/Chat/ChatBody.tsx` — this is a pure move of ChatPanel's content below the header (currently `ChatPanel.tsx:69-110`):
+
+```tsx
+import React from 'react'
+import { useSelector, useDispatch } from 'react-redux'
+import { Button } from '@mui/material'
+import { State, Dispatch } from '../../store'
+import { ChatMessages } from './ChatMessages'
+import { ChatApproval } from './ChatApproval'
+import { ChatInput } from './ChatInput'
+import { ChatOrgSelect } from './ChatOrgSelect'
+import { Notice } from '../Notice'
+
+/* Everything below the chat header — shared by the docked panel and the
+ popout window */
+export const ChatBody: React.FC = () => {
+ const chat = useSelector((state: State) => state.chat)
+ const dispatch = useDispatch()
+
+ return (
+ <>
+
+ {chat.health === 'unreachable' && (
+
+ Agent unreachable — is the dev service running on :3001?
+
+ )}
+ {chat.health === 'unauthorized' && (
+
+ <>
+ The AI agent needs its own sign-in to act on your behalf.
+
+ >
+
+ )}
+
+ {chat.pendingConfirmation && (
+ dispatch.chat.confirm(approved)}
+ />
+ )}
+ {chat.error && (
+ dispatch.chat.set({ error: null })}>
+ {chat.error}
+
+ )}
+
+ dispatch.chat.send(text)}
+ onStop={() => dispatch.chat.stop()}
+ />
+ >
+ )
+}
+```
+
+- [ ] **Step 2: Use it in ChatPanel**
+
+In `frontend/src/components/Chat/ChatPanel.tsx`, replace everything after the header `` (the ``, both `Notice` blocks, `…`, and `` — currently lines 69–110) with:
+
+```tsx
+
+```
+
+and update imports: add `import { ChatBody } from './ChatBody'`; remove the now-unused imports `Button` (keep `Box`, `Typography` from @mui/material), `ChatMessages`, `ChatApproval`, `ChatInput`, `ChatOrgSelect`, and `Notice`.
+
+- [ ] **Step 3: Typecheck**
+
+Run: `cd /Users/larrygunteriv/github/remoteit/desktop/frontend && npm run typecheck`
+Expected: clean — this is a pure extraction.
+
+- [ ] **Step 4: Commit**
+
+```bash
+cd /Users/larrygunteriv/github/remoteit/desktop/frontend && npx prettier --write src/components/Chat/ChatBody.tsx src/components/Chat/ChatPanel.tsx
+cd /Users/larrygunteriv/github/remoteit/desktop
+git add frontend/src/components/Chat/ChatBody.tsx frontend/src/components/Chat/ChatPanel.tsx
+git commit -m "refactor(chat): extract ChatBody shared by panel and popout"
+```
+
+---
+
+### Task 2: Boot flag, popout service skeleton, and `ChatWindow`
+
+**Files:**
+- Create: `frontend/src/services/chatPopout.ts`
+- Create: `frontend/src/components/Chat/ChatWindow.tsx`
+- Modify: `frontend/src/components/App.tsx`
+
+**Interfaces:**
+- Consumes: `ChatBody` from Task 1; `MODE` from `../constants`; `store` (components only, never the service).
+- Produces (Task 3 relies on these exact names):
+ - `chatPopout.ts`: `isChatPopout: boolean`, `CHAT_POPOUT_FLAG = 'chatPopout'`, `type ChatHandoff = { messages: ChatTranscriptMessage[]; conversationId: string; orgId: string | null }`.
+ - `ChatWindow: React.FC` — full-page chat for the popout.
+
+- [ ] **Step 1: Create the service with the boot flag**
+
+Create `frontend/src/services/chatPopout.ts`:
+
+```ts
+// import type only: the chat model value-imports this service (signout
+// broadcast), so a value import here would create a runtime cycle
+import type { ChatTranscriptMessage } from '../models/chat'
+
+/**
+ * Chat popout: the panel moves into its own window (same bundle, boot flag)
+ * and the conversation hands off over a BroadcastChannel. This module owns
+ * the flag, the channel, and the protocol; it never imports the store —
+ * callers inject handlers (avoids store/model import cycles).
+ */
+export const CHAT_POPOUT_FLAG = 'chatPopout'
+
+// Captured at module-evaluation time, before any routing can touch the URL
+// (same pattern as the hydra ?code capture in services/hydra.ts)
+export const isChatPopout = new URLSearchParams(window.location.search).has(CHAT_POPOUT_FLAG)
+
+export type ChatHandoff = {
+ messages: ChatTranscriptMessage[]
+ conversationId: string
+ orgId: string | null
+}
+```
+
+- [ ] **Step 2: Create ChatWindow**
+
+Create `frontend/src/components/Chat/ChatWindow.tsx` (protocol wiring comes in Task 3 — this step renders a working standalone chat):
+
+```tsx
+import React, { useEffect } from 'react'
+import { useDispatch } from 'react-redux'
+import { Box, Typography } from '@mui/material'
+import { Dispatch } from '../../store'
+import { IconButton } from '../../buttons/IconButton'
+import { ChatBody } from './ChatBody'
+
+/* Full-page chat for the popped-out window (?chatPopout boot flag). The
+ window chrome provides close; pop-in wiring lands with the protocol. */
+export const ChatWindow: React.FC = () => {
+ const dispatch = useDispatch()
+
+ useEffect(() => {
+ document.title = 'remote.it chat'
+ dispatch.chat.resetTransient()
+ dispatch.chat.syncOrg()
+ dispatch.chat.checkHealth()
+ }, [])
+
+ return (
+
+
+
+ New Chat
+
+ dispatch.chat.clearConversation()} />
+
+
+
+ )
+}
+```
+
+- [ ] **Step 3: Branch in App.tsx**
+
+In `frontend/src/components/App.tsx`:
+
+1. Add imports:
+
+```tsx
+import { ChatWindow } from './Chat/ChatWindow'
+import { isChatPopout } from '../services/chatPopout'
+```
+
+2. Replace the final `return` block's PersistGate content (currently the layout `` + `{showBottomMenu && }`) so the popout renders only the chat:
+
+```tsx
+ return (
+
+
+ }>
+ {MODE === 'development' && isChatPopout ? (
+
+ ) : (
+ <>
+
+ {hideSidebar ? : }
+
+ {MODE === 'development' && }
+
+ {showBottomMenu && }
+ >
+ )}
+
+
+ )
+```
+
+All pre-auth gates above the final return stay untouched (sign-in still works in the popout if needed).
+
+- [ ] **Step 4: Typecheck and verify render**
+
+Run: `cd /Users/larrygunteriv/github/remoteit/desktop/frontend && npm run typecheck`
+Expected: clean.
+
+If the vite dev server is running, open `http://localhost:3003/?chatPopout` in a browser tab — the bare chat should render (transcript rehydrates from persistence), no sidebar/router.
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /Users/larrygunteriv/github/remoteit/desktop/frontend && npx prettier --write src/services/chatPopout.ts src/components/Chat/ChatWindow.tsx src/components/App.tsx
+cd /Users/larrygunteriv/github/remoteit/desktop
+git add frontend/src/services/chatPopout.ts frontend/src/components/Chat/ChatWindow.tsx frontend/src/components/App.tsx
+git commit -m "feat(chat): standalone chat window behind ?chatPopout boot flag"
+```
+
+---
+
+### Task 3: Hand-off protocol, pop-out/pop-in buttons, crash resilience
+
+**Files:**
+- Modify: `frontend/src/services/chatPopout.ts`
+- Modify: `frontend/src/models/chat.ts`
+- Modify: `frontend/src/components/Chat/ChatPanel.tsx`
+- Modify: `frontend/src/components/Chat/ChatWindow.tsx`
+
+**Interfaces:**
+- Consumes: Task 2's `ChatHandoff`, `CHAT_POPOUT_FLAG`, `isChatPopout`; chat model reducers `set`, `adoptTranscript` (new).
+- Produces:
+ - Service: `openChatPopout(): boolean`, `initChatPopoutMain(handlers: PopoutMainHandlers): void`, `checkPopoutPresence(handlers: PopoutMainHandlers): void`, `initChatPopoutWindow(handlers: PopoutWindowHandlers): void`, `popIn(payload: ChatHandoff): void`, `broadcastChatSignout(): void`.
+ - Model: `IChatState.poppedOut: boolean`; reducer `adoptTranscript(state, payload: ChatHandoff)`.
+
+- [ ] **Step 1: Model additions**
+
+In `frontend/src/models/chat.ts`:
+
+1. Add to `IChatState` (after `orgId`) and to `defaultChatState` (`poppedOut: false`):
+
+```ts
+ /** Conversation currently lives in the popout window (main window only) */
+ poppedOut: boolean
+```
+
+2. Add the import at the top: `import { ChatHandoff, broadcastChatSignout } from '../services/chatPopout'`
+
+3. Add reducer (next to `clearConversation`):
+
+```ts
+ /* Hand-off: replace the conversation with the other window's copy */
+ adoptTranscript(state: IChatState, payload: ChatHandoff) {
+ state.messages = payload.messages
+ state.conversationId = payload.conversationId
+ state.orgId = payload.orgId
+ return state
+ },
+```
+
+4. In the `signOut` effect, broadcast to the popout FIRST (it closes without a handback; sign-out clears the transcript anyway):
+
+```ts
+ async signOut() {
+ broadcastChatSignout()
+ abortController?.abort()
+ abortController = null
+ dispatch.chat.reset()
+ await agentSignOut()
+ },
+```
+
+Note: `services/chatPopout.ts` must not import the store or any model — the import direction is model → service only.
+
+- [ ] **Step 2: Protocol implementation in the service**
+
+Append to `frontend/src/services/chatPopout.ts`:
+
+```ts
+type PopoutMessage =
+ | { type: 'hello' }
+ | { type: 'adopt'; payload: ChatHandoff }
+ | { type: 'handback'; payload: ChatHandoff }
+ | { type: 'ping' }
+ | { type: 'alive' }
+ | { type: 'signout' }
+
+export type PopoutMainHandlers = {
+ getHandoff: () => ChatHandoff
+ /** handback arrived: apply the transcript and reopen the dock */
+ adopt: (payload: ChatHandoff) => void
+ /** popout said hello: hide the dock */
+ onPopoutOpened: () => void
+ /** popout vanished without a handback: reopen the dock as-is */
+ onPopoutLost: () => void
+ /** boot reconciliation: does a popout exist right now? */
+ onPresence: (present: boolean) => void
+}
+
+export type PopoutWindowHandlers = {
+ adopt: (payload: ChatHandoff) => void
+ getHandoff: () => ChatHandoff
+ onSignout: () => void
+}
+
+const CHANNEL = 'remoteit-chat-popout'
+const WINDOW_NAME = 'remoteit-chat'
+const WINDOW_FEATURES = 'popup=yes,width=520,height=780'
+const POLL_INTERVAL = 2000
+const PRESENCE_TIMEOUT = 500
+
+const channel = typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel(CHANNEL) : null
+const post = (message: PopoutMessage) => channel?.postMessage(message)
+
+let popoutWindow: Window | null = null
+let pollTimer: number | undefined
+let alivePending = false
+let suppressHandback = false
+
+/* ---------- main-window side ---------- */
+
+export function openChatPopout(): boolean {
+ const opened = window.open(`${window.location.origin}/?${CHAT_POPOUT_FLAG}`, WINDOW_NAME, WINDOW_FEATURES)
+ if (!opened) return false // popup blocked — dock stays; hello never arrives
+ popoutWindow = opened
+ return true
+}
+
+export function initChatPopoutMain(handlers: PopoutMainHandlers): void {
+ if (!channel) return
+ channel.addEventListener('message', (event: MessageEvent) => {
+ switch (event.data.type) {
+ case 'hello':
+ post({ type: 'adopt', payload: handlers.getHandoff() })
+ handlers.onPopoutOpened()
+ startPolling(handlers)
+ break
+ case 'handback':
+ stopPolling()
+ handlers.adopt(event.data.payload)
+ break
+ case 'alive':
+ alivePending = false
+ break
+ }
+ })
+}
+
+/* Ask whether a popout survives from a previous page load; corrects a stale
+ persisted poppedOut flag either way */
+export function checkPopoutPresence(handlers: PopoutMainHandlers): void {
+ if (!channel) {
+ handlers.onPresence(false)
+ return
+ }
+ alivePending = true
+ post({ type: 'ping' })
+ window.setTimeout(() => {
+ if (alivePending) {
+ handlers.onPresence(false)
+ } else {
+ handlers.onPresence(true)
+ startPolling(handlers)
+ }
+ }, PRESENCE_TIMEOUT)
+}
+
+export function broadcastChatSignout(): void {
+ post({ type: 'signout' })
+}
+
+/* Crash net: a popout that dies without beforeunload still restores the
+ dock. Uses the window handle when we have one (same page load), pings
+ otherwise (main was reloaded while popped out). */
+function startPolling(handlers: PopoutMainHandlers) {
+ if (pollTimer) return
+ pollTimer = window.setInterval(() => {
+ if (popoutWindow) {
+ if (popoutWindow.closed) lost(handlers)
+ return
+ }
+ alivePending = true
+ post({ type: 'ping' })
+ window.setTimeout(() => {
+ if (alivePending && pollTimer) lost(handlers)
+ }, PRESENCE_TIMEOUT)
+ }, POLL_INTERVAL)
+}
+
+function stopPolling() {
+ if (pollTimer) window.clearInterval(pollTimer)
+ pollTimer = undefined
+ popoutWindow = null
+}
+
+function lost(handlers: PopoutMainHandlers) {
+ stopPolling()
+ handlers.onPopoutLost()
+}
+
+/* ---------- popout-window side ---------- */
+
+export function initChatPopoutWindow(handlers: PopoutWindowHandlers): void {
+ if (!channel) return
+ channel.addEventListener('message', (event: MessageEvent) => {
+ switch (event.data.type) {
+ case 'adopt':
+ // Main's copy is authoritative at hand-off; until it arrives the
+ // window shows its own rehydrated (persisted) transcript
+ handlers.adopt(event.data.payload)
+ break
+ case 'ping':
+ post({ type: 'alive' })
+ break
+ case 'signout':
+ suppressHandback = true // sign-out clears the transcript; nothing to hand back
+ handlers.onSignout()
+ break
+ }
+ })
+ window.addEventListener('beforeunload', () => {
+ if (!suppressHandback) post({ type: 'handback', payload: handlers.getHandoff() })
+ })
+ post({ type: 'hello' })
+}
+
+export function popIn(payload: ChatHandoff): void {
+ post({ type: 'handback', payload })
+ suppressHandback = true // beforeunload would duplicate it (harmless but noisy)
+ window.close()
+}
+```
+
+- [ ] **Step 3: Wire the main window (ChatPanel)**
+
+In `frontend/src/components/Chat/ChatPanel.tsx`:
+
+1. Imports: add `browser` service, popout service, and store:
+
+```tsx
+import browser from '../../services/browser'
+import { store, State, Dispatch } from '../../store'
+import { openChatPopout, initChatPopoutMain, checkPopoutPresence, PopoutMainHandlers, ChatHandoff } from '../../services/chatPopout'
+```
+
+2. Above the component, the handoff snapshot helper:
+
+```tsx
+const currentHandoff = (): ChatHandoff => {
+ const c = store.getState().chat
+ return { messages: c.messages, conversationId: c.conversationId, orgId: c.orgId }
+}
+```
+
+3. Inside the component, replace the existing mount effect (the one calling `handleSignInCallback`) with one that also wires the protocol:
+
+```tsx
+ // Completes a Hydra sign-in redirect if this page load carries ?code —
+ // runs on mount regardless of whether the panel is open
+ useEffect(() => {
+ dispatch.chat.handleSignInCallback()
+ const handlers: PopoutMainHandlers = {
+ getHandoff: currentHandoff,
+ adopt: payload => {
+ dispatch.chat.adoptTranscript(payload)
+ dispatch.chat.set({ poppedOut: false, open: true })
+ },
+ onPopoutOpened: () => dispatch.chat.set({ open: false, poppedOut: true }),
+ onPopoutLost: () => dispatch.chat.set({ poppedOut: false, open: true }),
+ onPresence: present => dispatch.chat.set(present ? { poppedOut: true, open: false } : { poppedOut: false }),
+ }
+ initChatPopoutMain(handlers)
+ checkPopoutPresence(handlers)
+ }, [])
+```
+
+4. Add the pop-out button to the header, before the New Chat button (browser/Electron only — never mobile):
+
+```tsx
+ {!browser.isMobile && (
+ openChatPopout()} />
+ )}
+```
+
+The dock hides when the popout's `hello` arrives — a blocked popup therefore changes nothing.
+
+- [ ] **Step 4: Wire the popout window (ChatWindow)**
+
+In `frontend/src/components/Chat/ChatWindow.tsx`:
+
+1. Imports: add `import { store } from '../../store'` (extend the existing store import) and `import { initChatPopoutWindow, popIn, ChatHandoff } from '../../services/chatPopout'`.
+
+2. Above the component:
+
+```tsx
+const currentHandoff = (): ChatHandoff => {
+ const c = store.getState().chat
+ return { messages: c.messages, conversationId: c.conversationId, orgId: c.orgId }
+}
+```
+
+3. In the mount effect, after `checkHealth()`:
+
+```tsx
+ initChatPopoutWindow({
+ adopt: payload => dispatch.chat.adoptTranscript(payload),
+ getHandoff: currentHandoff,
+ onSignout: () => window.close(),
+ })
+```
+
+4. Add the pop-in button after the New Chat button (stop any stream first — the open message is marked interrupted by the existing stop path):
+
+```tsx
+ {
+ await dispatch.chat.stop()
+ popIn(currentHandoff())
+ }}
+ />
+```
+
+- [ ] **Step 5: Typecheck**
+
+Run: `cd /Users/larrygunteriv/github/remoteit/desktop/frontend && npm run typecheck`
+Expected: clean.
+
+- [ ] **Step 6: Manual verification (browser)**
+
+With vite (`:3003`) and the ai-agent service (`:3001`) running, signed in, chat open with some transcript:
+
+1. Click **Pop out** → window opens with the transcript and org selection; docked panel hides.
+2. Send a message in the popout (org scoping still applies), click **Pop back in** → window closes, dock returns with the full conversation.
+3. Pop out again, close the popout with the window's X → dock returns with the conversation.
+4. Pop out, then reload the MAIN window → dock stays hidden (presence ping); popout unaffected.
+5. Kill the popout without unload (e.g. from a task manager, or fake it: DevTools on the popout → `window.stop()` won't do it — acceptable to skip if awkward; the `window.closed` poll path is exercised by step 3 when beforeunload is raced).
+6. Sign out of the app in the main window → popout closes.
+
+Record what you verified in your report; note any step you could not perform.
+
+- [ ] **Step 7: Commit**
+
+```bash
+cd /Users/larrygunteriv/github/remoteit/desktop/frontend && npx prettier --write src/services/chatPopout.ts src/models/chat.ts src/components/Chat/ChatPanel.tsx src/components/Chat/ChatWindow.tsx
+cd /Users/larrygunteriv/github/remoteit/desktop
+git add frontend/src/services/chatPopout.ts frontend/src/models/chat.ts frontend/src/components/Chat/ChatPanel.tsx frontend/src/components/Chat/ChatWindow.tsx
+git commit -m "feat(chat): pop the chat out to its own window with transcript hand-off"
+```
+
+---
+
+### Task 4: Electron window-open allow-branch
+
+**Files:**
+- Modify: `electron/src/ElectronApp.ts:248-252` (the `setWindowOpenHandler` block)
+
+**Interfaces:**
+- Consumes: the popout URL shape from Task 2 (`/?chatPopout`); `this.getStartUrl()` (`ElectronApp.ts:339`).
+- Produces: popout opens as a native BrowserWindow in Electron; all other URLs keep opening externally.
+
+- [ ] **Step 1: Implement the branch**
+
+Replace the current handler (`ElectronApp.ts:248-252`):
+
+```ts
+ this.window.webContents.setWindowOpenHandler(({ url }) => {
+ // The dev chat panel pops out into its own window (?chatPopout on our
+ // own origin); every other window.open goes to the system browser.
+ try {
+ const parsed = new URL(url)
+ if (parsed.origin === new URL(this.getStartUrl()).origin && parsed.searchParams.has('chatPopout')) {
+ return {
+ action: 'allow',
+ overrideBrowserWindowOptions: {
+ width: 520,
+ height: 780,
+ minWidth: 360,
+ minHeight: 500,
+ autoHideMenuBar: true,
+ },
+ }
+ }
+ } catch {}
+ Logger.info('OPEN EXTERNAL URL', { url })
+ electron.shell.openExternal(url)
+ return { action: 'deny' }
+ })
+```
+
+- [ ] **Step 2: Typecheck**
+
+Run: `cd /Users/larrygunteriv/github/remoteit/desktop/electron && npm run typecheck`
+Expected: clean.
+
+- [ ] **Step 3: Commit**
+
+```bash
+cd /Users/larrygunteriv/github/remoteit/desktop
+git add electron/src/ElectronApp.ts
+git commit -m "feat(electron): open the chat popout as a native window"
+```
From 99d2ae0af4c8604cc22743d39854ffe20b175689 Mon Sep 17 00:00:00 2001
From: thelg4
Date: Tue, 28 Jul 2026 11:23:35 -0400
Subject: [PATCH 016/238] refactor(chat): extract ChatBody shared by panel and
popout
---
frontend/src/components/Chat/ChatBody.tsx | 63 ++++++++++++++++++++++
frontend/src/components/Chat/ChatPanel.tsx | 51 ++----------------
2 files changed, 66 insertions(+), 48 deletions(-)
create mode 100644 frontend/src/components/Chat/ChatBody.tsx
diff --git a/frontend/src/components/Chat/ChatBody.tsx b/frontend/src/components/Chat/ChatBody.tsx
new file mode 100644
index 000000000..fa06caa52
--- /dev/null
+++ b/frontend/src/components/Chat/ChatBody.tsx
@@ -0,0 +1,63 @@
+import React from 'react'
+import { useSelector, useDispatch } from 'react-redux'
+import { Button } from '@mui/material'
+import { State, Dispatch } from '../../store'
+import { ChatMessages } from './ChatMessages'
+import { ChatApproval } from './ChatApproval'
+import { ChatInput } from './ChatInput'
+import { ChatOrgSelect } from './ChatOrgSelect'
+import { Notice } from '../Notice'
+
+/* Everything below the chat header — shared by the docked panel and the
+ popout window */
+export const ChatBody: React.FC = () => {
+ const chat = useSelector((state: State) => state.chat)
+ const dispatch = useDispatch()
+
+ return (
+ <>
+
+ {chat.health === 'unreachable' && (
+
+ Agent unreachable — is the dev service running on :3001?
+
+ )}
+ {chat.health === 'unauthorized' && (
+
+ <>
+ The AI agent needs its own sign-in to act on your behalf.
+
+ >
+
+ )}
+
+ {chat.pendingConfirmation && (
+ dispatch.chat.confirm(approved)}
+ />
+ )}
+ {chat.error && (
+ dispatch.chat.set({ error: null })}>
+ {chat.error}
+
+ )}
+
+ dispatch.chat.send(text)}
+ onStop={() => dispatch.chat.stop()}
+ />
+ >
+ )
+}
diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx
index caf8061aa..9cf764fb9 100644
--- a/frontend/src/components/Chat/ChatPanel.tsx
+++ b/frontend/src/components/Chat/ChatPanel.tsx
@@ -1,14 +1,10 @@
import React, { useEffect } from 'react'
import { useSelector, useDispatch } from 'react-redux'
-import { Box, Button, Typography } from '@mui/material'
+import { Box, Typography } from '@mui/material'
import { State, Dispatch } from '../../store'
import { CHAT_PANEL_WIDTH, CHAT_PANEL_WIDTH_EXPANDED } from '../../constants'
import { IconButton } from '../../buttons/IconButton'
-import { ChatMessages } from './ChatMessages'
-import { ChatApproval } from './ChatApproval'
-import { ChatInput } from './ChatInput'
-import { ChatOrgSelect } from './ChatOrgSelect'
-import { Notice } from '../Notice'
+import { ChatBody } from './ChatBody'
export const ChatPanel: React.FC = () => {
const chat = useSelector((state: State) => state.chat)
@@ -66,48 +62,7 @@ export const ChatPanel: React.FC = () => {
dispatch.chat.clearConversation()} />
dispatch.chat.set({ open: false })} />
-
- {chat.health === 'unreachable' && (
-
- Agent unreachable — is the dev service running on :3001?
-
- )}
- {chat.health === 'unauthorized' && (
-
- <>
- The AI agent needs its own sign-in to act on your behalf.
-
- >
-
- )}
-
- {chat.pendingConfirmation && (
- dispatch.chat.confirm(approved)}
- />
- )}
- {chat.error && (
- dispatch.chat.set({ error: null })}>
- {chat.error}
-
- )}
-
- dispatch.chat.send(text)}
- onStop={() => dispatch.chat.stop()}
- />
+
)
}
From 8a93e028dfe2a3a8ab33d122d869f3ae7f49910a Mon Sep 17 00:00:00 2001
From: thelg4
Date: Tue, 28 Jul 2026 11:26:56 -0400
Subject: [PATCH 017/238] feat(chat): standalone chat window behind ?chatPopout
boot flag
---
frontend/src/components/App.tsx | 40 ++++++++++++---------
frontend/src/components/Chat/ChatWindow.tsx | 40 +++++++++++++++++++++
frontend/src/services/chatPopout.ts | 21 +++++++++++
3 files changed, 85 insertions(+), 16 deletions(-)
create mode 100644 frontend/src/components/Chat/ChatWindow.tsx
create mode 100644 frontend/src/services/chatPopout.ts
diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx
index 74220ca2f..52f1cab1a 100644
--- a/frontend/src/components/App.tsx
+++ b/frontend/src/components/App.tsx
@@ -31,12 +31,14 @@ import { SignInPage } from '../pages/SignInPage'
import { BottomMenu } from './BottomMenu'
import { Sidebar } from './Sidebar'
import { ChatPanel } from './Chat/ChatPanel'
+import { ChatWindow } from './Chat/ChatWindow'
import { Router } from '../routers/Router'
import { Page } from '../pages/Page'
import { Logo } from '@common/brand/Logo'
import { ViewAsBanner } from './ViewAsBanner'
import { AnnouncementDialog } from './AnnouncementDialog'
import { AnnouncementBanner } from './AnnouncementBanner'
+import { isChatPopout } from '../services/chatPopout'
export const App: React.FC = () => {
// Subscribe the whole app to i18next language changes and lazy-locale loads, so
@@ -133,22 +135,28 @@ export const App: React.FC = () => {
}>
-
- {hideSidebar ? : }
-
- {MODE === 'development' && }
-
- {showBottomMenu && }
+ {MODE === 'development' && isChatPopout ? (
+
+ ) : (
+ <>
+
+ {hideSidebar ? : }
+
+ {MODE === 'development' && }
+
+ {showBottomMenu && }
+ >
+ )}
diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx
new file mode 100644
index 000000000..19ad8a846
--- /dev/null
+++ b/frontend/src/components/Chat/ChatWindow.tsx
@@ -0,0 +1,40 @@
+import React, { useEffect } from 'react'
+import { useDispatch } from 'react-redux'
+import { Box, Typography } from '@mui/material'
+import { Dispatch } from '../../store'
+import { IconButton } from '../../buttons/IconButton'
+import { ChatBody } from './ChatBody'
+
+/* Full-page chat for the popped-out window (?chatPopout boot flag). The
+ window chrome provides close; pop-in wiring lands with the protocol. */
+export const ChatWindow: React.FC = () => {
+ const dispatch = useDispatch()
+
+ useEffect(() => {
+ document.title = 'remote.it chat'
+ dispatch.chat.resetTransient()
+ dispatch.chat.syncOrg()
+ dispatch.chat.checkHealth()
+ }, [])
+
+ return (
+
+
+
+ New Chat
+
+ dispatch.chat.clearConversation()} />
+
+
+
+ )
+}
diff --git a/frontend/src/services/chatPopout.ts b/frontend/src/services/chatPopout.ts
new file mode 100644
index 000000000..e951830d6
--- /dev/null
+++ b/frontend/src/services/chatPopout.ts
@@ -0,0 +1,21 @@
+// import type only: the chat model value-imports this service (signout
+// broadcast), so a value import here would create a runtime cycle
+import type { ChatTranscriptMessage } from '../models/chat'
+
+/**
+ * Chat popout: the panel moves into its own window (same bundle, boot flag)
+ * and the conversation hands off over a BroadcastChannel. This module owns
+ * the flag, the channel, and the protocol; it never imports the store —
+ * callers inject handlers (avoids store/model import cycles).
+ */
+export const CHAT_POPOUT_FLAG = 'chatPopout'
+
+// Captured at module-evaluation time, before any routing can touch the URL
+// (same pattern as the hydra ?code capture in services/hydra.ts)
+export const isChatPopout = new URLSearchParams(window.location.search).has(CHAT_POPOUT_FLAG)
+
+export type ChatHandoff = {
+ messages: ChatTranscriptMessage[]
+ conversationId: string
+ orgId: string | null
+}
From 62027bcffc2c69ee921a3955aa6d895cee28598e Mon Sep 17 00:00:00 2001
From: thelg4
Date: Tue, 28 Jul 2026 11:32:12 -0400
Subject: [PATCH 018/238] feat(chat): pop the chat out to its own window with
transcript hand-off
---
frontend/src/components/Chat/ChatPanel.tsx | 30 +++-
frontend/src/components/Chat/ChatWindow.tsx | 21 ++-
frontend/src/models/chat.ts | 16 +-
frontend/src/services/chatPopout.ts | 153 ++++++++++++++++++++
4 files changed, 217 insertions(+), 3 deletions(-)
diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx
index 9cf764fb9..65279b7c0 100644
--- a/frontend/src/components/Chat/ChatPanel.tsx
+++ b/frontend/src/components/Chat/ChatPanel.tsx
@@ -1,10 +1,23 @@
import React, { useEffect } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { Box, Typography } from '@mui/material'
-import { State, Dispatch } from '../../store'
+import { store, State, Dispatch } from '../../store'
import { CHAT_PANEL_WIDTH, CHAT_PANEL_WIDTH_EXPANDED } from '../../constants'
import { IconButton } from '../../buttons/IconButton'
import { ChatBody } from './ChatBody'
+import browser from '../../services/browser'
+import {
+ openChatPopout,
+ initChatPopoutMain,
+ checkPopoutPresence,
+ PopoutMainHandlers,
+ ChatHandoff,
+} from '../../services/chatPopout'
+
+const currentHandoff = (): ChatHandoff => {
+ const c = store.getState().chat
+ return { messages: c.messages, conversationId: c.conversationId, orgId: c.orgId }
+}
export const ChatPanel: React.FC = () => {
const chat = useSelector((state: State) => state.chat)
@@ -15,6 +28,18 @@ export const ChatPanel: React.FC = () => {
// runs on mount regardless of whether the panel is open
useEffect(() => {
dispatch.chat.handleSignInCallback()
+ const handlers: PopoutMainHandlers = {
+ getHandoff: currentHandoff,
+ adopt: payload => {
+ dispatch.chat.adoptTranscript(payload)
+ dispatch.chat.set({ poppedOut: false, open: true })
+ },
+ onPopoutOpened: () => dispatch.chat.set({ open: false, poppedOut: true }),
+ onPopoutLost: () => dispatch.chat.set({ poppedOut: false, open: true }),
+ onPresence: present => dispatch.chat.set(present ? { poppedOut: true, open: false } : { poppedOut: false }),
+ }
+ initChatPopoutMain(handlers)
+ checkPopoutPresence(handlers)
}, [])
useEffect(() => {
@@ -59,6 +84,9 @@ export const ChatPanel: React.FC = () => {
onClick={() => dispatch.chat.set({ expanded: !chat.expanded })}
/>
)}
+ {!browser.isMobile && (
+ openChatPopout()} />
+ )}
dispatch.chat.clearConversation()} />
dispatch.chat.set({ open: false })} />
diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx
index 19ad8a846..944fae773 100644
--- a/frontend/src/components/Chat/ChatWindow.tsx
+++ b/frontend/src/components/Chat/ChatWindow.tsx
@@ -1,9 +1,15 @@
import React, { useEffect } from 'react'
import { useDispatch } from 'react-redux'
import { Box, Typography } from '@mui/material'
-import { Dispatch } from '../../store'
+import { store, Dispatch } from '../../store'
import { IconButton } from '../../buttons/IconButton'
import { ChatBody } from './ChatBody'
+import { initChatPopoutWindow, popIn, ChatHandoff } from '../../services/chatPopout'
+
+const currentHandoff = (): ChatHandoff => {
+ const c = store.getState().chat
+ return { messages: c.messages, conversationId: c.conversationId, orgId: c.orgId }
+}
/* Full-page chat for the popped-out window (?chatPopout boot flag). The
window chrome provides close; pop-in wiring lands with the protocol. */
@@ -15,6 +21,11 @@ export const ChatWindow: React.FC = () => {
dispatch.chat.resetTransient()
dispatch.chat.syncOrg()
dispatch.chat.checkHealth()
+ initChatPopoutWindow({
+ adopt: payload => dispatch.chat.adoptTranscript(payload),
+ getHandoff: currentHandoff,
+ onSignout: () => window.close(),
+ })
}, [])
return (
@@ -33,6 +44,14 @@ export const ChatWindow: React.FC = () => {
New Chat
dispatch.chat.clearConversation()} />
+ {
+ await dispatch.chat.stop()
+ popIn(currentHandoff())
+ }}
+ />
diff --git a/frontend/src/models/chat.ts b/frontend/src/models/chat.ts
index dde57d85c..8ceab542c 100644
--- a/frontend/src/models/chat.ts
+++ b/frontend/src/models/chat.ts
@@ -12,6 +12,7 @@ import {
OrgSelection,
} from '../services/agent'
import { startAgentSignIn, handleAgentSignInCallback, ensureFreshAgentToken, agentSignOut } from '../services/hydra'
+import { ChatHandoff, broadcastChatSignout } from '../services/chatPopout'
export type ChatToolCall = {
id: string
@@ -32,6 +33,8 @@ export type IChatState = {
conversationId: string
/** Org the agent is scoped to; null = uninitialized, user id = personal */
orgId: string | null
+ /** Conversation currently lives in the popout window (main window only) */
+ poppedOut: boolean
streaming: boolean
pendingConfirmation: { toolUseId: string; toolName: string; input: Record } | null
error: string | null
@@ -44,6 +47,7 @@ export const defaultChatState: IChatState = {
messages: [],
conversationId: '',
orgId: null,
+ poppedOut: false,
streaming: false,
pendingConfirmation: null,
error: null,
@@ -130,7 +134,9 @@ export default createModel()({
// present; fall back to the name carried on the membership itself so
// org scope never silently degrades to personal.
const name = (
- state.organization.accounts[orgId]?.name || state.accounts.membership.find(m => m.account.id === orgId)?.name || ''
+ state.organization.accounts[orgId]?.name ||
+ state.accounts.membership.find(m => m.account.id === orgId)?.name ||
+ ''
).trim()
const isMember = state.accounts.membership.some(m => m.account.id === orgId)
if (name && isMember) org = { id: orgId, name }
@@ -222,6 +228,7 @@ export default createModel()({
/* App sign-out tears the agent session down with it: revoke + clear the
Hydra credentials and drop the transcript */
async signOut() {
+ broadcastChatSignout()
abortController?.abort()
abortController = null
dispatch.chat.reset()
@@ -248,6 +255,13 @@ export default createModel()({
state.health = 'unknown'
return state
},
+ /* Hand-off: replace the conversation with the other window's copy */
+ adoptTranscript(state: IChatState, payload: ChatHandoff) {
+ state.messages = payload.messages
+ state.conversationId = payload.conversationId
+ state.orgId = payload.orgId
+ return state
+ },
clearConversation(state: IChatState) {
state.messages = []
state.conversationId = ''
diff --git a/frontend/src/services/chatPopout.ts b/frontend/src/services/chatPopout.ts
index e951830d6..8f2c1a452 100644
--- a/frontend/src/services/chatPopout.ts
+++ b/frontend/src/services/chatPopout.ts
@@ -19,3 +19,156 @@ export type ChatHandoff = {
conversationId: string
orgId: string | null
}
+
+type PopoutMessage =
+ | { type: 'hello' }
+ | { type: 'adopt'; payload: ChatHandoff }
+ | { type: 'handback'; payload: ChatHandoff }
+ | { type: 'ping' }
+ | { type: 'alive' }
+ | { type: 'signout' }
+
+export type PopoutMainHandlers = {
+ getHandoff: () => ChatHandoff
+ /** handback arrived: apply the transcript and reopen the dock */
+ adopt: (payload: ChatHandoff) => void
+ /** popout said hello: hide the dock */
+ onPopoutOpened: () => void
+ /** popout vanished without a handback: reopen the dock as-is */
+ onPopoutLost: () => void
+ /** boot reconciliation: does a popout exist right now? */
+ onPresence: (present: boolean) => void
+}
+
+export type PopoutWindowHandlers = {
+ adopt: (payload: ChatHandoff) => void
+ getHandoff: () => ChatHandoff
+ onSignout: () => void
+}
+
+const CHANNEL = 'remoteit-chat-popout'
+const WINDOW_NAME = 'remoteit-chat'
+const WINDOW_FEATURES = 'popup=yes,width=520,height=780'
+const POLL_INTERVAL = 2000
+const PRESENCE_TIMEOUT = 500
+
+const channel = typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel(CHANNEL) : null
+const post = (message: PopoutMessage) => channel?.postMessage(message)
+
+let popoutWindow: Window | null = null
+let pollTimer: number | undefined
+let alivePending = false
+let suppressHandback = false
+
+/* ---------- main-window side ---------- */
+
+export function openChatPopout(): boolean {
+ const opened = window.open(`${window.location.origin}/?${CHAT_POPOUT_FLAG}`, WINDOW_NAME, WINDOW_FEATURES)
+ if (!opened) return false // popup blocked — dock stays; hello never arrives
+ popoutWindow = opened
+ return true
+}
+
+export function initChatPopoutMain(handlers: PopoutMainHandlers): void {
+ if (!channel) return
+ channel.addEventListener('message', (event: MessageEvent) => {
+ switch (event.data.type) {
+ case 'hello':
+ post({ type: 'adopt', payload: handlers.getHandoff() })
+ handlers.onPopoutOpened()
+ startPolling(handlers)
+ break
+ case 'handback':
+ stopPolling()
+ handlers.adopt(event.data.payload)
+ break
+ case 'alive':
+ alivePending = false
+ break
+ }
+ })
+}
+
+/* Ask whether a popout survives from a previous page load; corrects a stale
+ persisted poppedOut flag either way */
+export function checkPopoutPresence(handlers: PopoutMainHandlers): void {
+ if (!channel) {
+ handlers.onPresence(false)
+ return
+ }
+ alivePending = true
+ post({ type: 'ping' })
+ window.setTimeout(() => {
+ if (alivePending) {
+ handlers.onPresence(false)
+ } else {
+ handlers.onPresence(true)
+ startPolling(handlers)
+ }
+ }, PRESENCE_TIMEOUT)
+}
+
+export function broadcastChatSignout(): void {
+ post({ type: 'signout' })
+}
+
+/* Crash net: a popout that dies without beforeunload still restores the
+ dock. Uses the window handle when we have one (same page load), pings
+ otherwise (main was reloaded while popped out). */
+function startPolling(handlers: PopoutMainHandlers) {
+ if (pollTimer) return
+ pollTimer = window.setInterval(() => {
+ if (popoutWindow) {
+ if (popoutWindow.closed) lost(handlers)
+ return
+ }
+ alivePending = true
+ post({ type: 'ping' })
+ window.setTimeout(() => {
+ if (alivePending && pollTimer) lost(handlers)
+ }, PRESENCE_TIMEOUT)
+ }, POLL_INTERVAL)
+}
+
+function stopPolling() {
+ if (pollTimer) window.clearInterval(pollTimer)
+ pollTimer = undefined
+ popoutWindow = null
+}
+
+function lost(handlers: PopoutMainHandlers) {
+ stopPolling()
+ handlers.onPopoutLost()
+}
+
+/* ---------- popout-window side ---------- */
+
+export function initChatPopoutWindow(handlers: PopoutWindowHandlers): void {
+ if (!channel) return
+ channel.addEventListener('message', (event: MessageEvent) => {
+ switch (event.data.type) {
+ case 'adopt':
+ // Main's copy is authoritative at hand-off; until it arrives the
+ // window shows its own rehydrated (persisted) transcript
+ handlers.adopt(event.data.payload)
+ break
+ case 'ping':
+ post({ type: 'alive' })
+ break
+ case 'signout':
+ suppressHandback = true // sign-out clears the transcript; nothing to hand back
+ handlers.onSignout()
+ break
+ }
+ })
+ window.addEventListener('beforeunload', () => {
+ if (!suppressHandback) post({ type: 'handback', payload: handlers.getHandoff() })
+ })
+ post({ type: 'hello' })
+}
+
+export function popIn(payload: ChatHandoff): void {
+ post({ type: 'handback', payload })
+ suppressHandback = true // beforeunload would duplicate it (harmless but noisy)
+ window.close()
+}
From 7909fcc5f0ebc1871d6cd17864da1bc2fa932499 Mon Sep 17 00:00:00 2001
From: thelg4
Date: Tue, 28 Jul 2026 11:37:39 -0400
Subject: [PATCH 019/238] feat(electron): open the chat popout as a native
window
---
electron/src/ElectronApp.ts | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
diff --git a/electron/src/ElectronApp.ts b/electron/src/ElectronApp.ts
index f23b110e4..a153cf3d9 100644
--- a/electron/src/ElectronApp.ts
+++ b/electron/src/ElectronApp.ts
@@ -248,6 +248,23 @@ export default class ElectronApp {
})
this.window.webContents.setWindowOpenHandler(({ url }) => {
+ // The dev chat panel pops out into its own window (?chatPopout on our
+ // own origin); every other window.open goes to the system browser.
+ try {
+ const parsed = new URL(url)
+ if (parsed.origin === new URL(this.getStartUrl()).origin && parsed.searchParams.has('chatPopout')) {
+ return {
+ action: 'allow',
+ overrideBrowserWindowOptions: {
+ width: 520,
+ height: 780,
+ minWidth: 360,
+ minHeight: 500,
+ autoHideMenuBar: true,
+ },
+ }
+ }
+ } catch {}
Logger.info('OPEN EXTERNAL URL', { url })
electron.shell.openExternal(url)
return { action: 'deny' }
From 202506a723c49d208fb1f29b0d01ffce394fba0c Mon Sep 17 00:00:00 2001
From: thelg4
Date: Tue, 28 Jul 2026 11:51:19 -0400
Subject: [PATCH 020/238] =?UTF-8?q?fix(chat):=20final=20review=20=E2=80=94?=
=?UTF-8?q?=20popout=20stream,=20sign-in,=20and=20lifecycle=20hardening?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
frontend/src/components/Chat/ChatBody.tsx | 23 ++++++-----
frontend/src/components/Chat/ChatPanel.tsx | 8 +++-
frontend/src/components/Chat/ChatWindow.tsx | 3 +-
frontend/src/components/Header/Header.tsx | 13 +++++-
frontend/src/models/chat.ts | 9 +++--
frontend/src/services/chatPopout.ts | 44 +++++++++++++++------
6 files changed, 71 insertions(+), 29 deletions(-)
diff --git a/frontend/src/components/Chat/ChatBody.tsx b/frontend/src/components/Chat/ChatBody.tsx
index fa06caa52..c976d1ae6 100644
--- a/frontend/src/components/Chat/ChatBody.tsx
+++ b/frontend/src/components/Chat/ChatBody.tsx
@@ -7,6 +7,7 @@ import { ChatApproval } from './ChatApproval'
import { ChatInput } from './ChatInput'
import { ChatOrgSelect } from './ChatOrgSelect'
import { Notice } from '../Notice'
+import { isChatPopout } from '../../services/chatPopout'
/* Everything below the chat header — shared by the docked panel and the
popout window */
@@ -26,15 +27,19 @@ export const ChatBody: React.FC = () => {
<>
The AI agent needs its own sign-in to act on your behalf.
-
+ {isChatPopout ? (
+ ' Sign in from the main app window.'
+ ) : (
+
+ )}
>
)}
diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx
index 65279b7c0..2e48acdce 100644
--- a/frontend/src/components/Chat/ChatPanel.tsx
+++ b/frontend/src/components/Chat/ChatPanel.tsx
@@ -34,12 +34,16 @@ export const ChatPanel: React.FC = () => {
dispatch.chat.adoptTranscript(payload)
dispatch.chat.set({ poppedOut: false, open: true })
},
- onPopoutOpened: () => dispatch.chat.set({ open: false, poppedOut: true }),
+ onPopoutOpened: () => {
+ dispatch.chat.stop()
+ dispatch.chat.set({ open: false, poppedOut: true })
+ },
onPopoutLost: () => dispatch.chat.set({ poppedOut: false, open: true }),
onPresence: present => dispatch.chat.set(present ? { poppedOut: true, open: false } : { poppedOut: false }),
}
- initChatPopoutMain(handlers)
+ const unsubscribe = initChatPopoutMain(handlers)
checkPopoutPresence(handlers)
+ return unsubscribe
}, [])
useEffect(() => {
diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx
index 944fae773..46f89e0a6 100644
--- a/frontend/src/components/Chat/ChatWindow.tsx
+++ b/frontend/src/components/Chat/ChatWindow.tsx
@@ -21,11 +21,12 @@ export const ChatWindow: React.FC = () => {
dispatch.chat.resetTransient()
dispatch.chat.syncOrg()
dispatch.chat.checkHealth()
- initChatPopoutWindow({
+ const unsubscribe = initChatPopoutWindow({
adopt: payload => dispatch.chat.adoptTranscript(payload),
getHandoff: currentHandoff,
onSignout: () => window.close(),
})
+ return unsubscribe
}, [])
return (
diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx
index 3362dc64a..f9de4d42e 100644
--- a/frontend/src/components/Header/Header.tsx
+++ b/frontend/src/components/Header/Header.tsx
@@ -30,6 +30,7 @@ export const Header: React.FC = ({ panels = 1 }) => {
const { searched } = useSelector(selectDeviceModelAttributes)
const permissions = useSelector(selectPermissions)
const chatOpen = useSelector((state: State) => state.chat.open)
+ const chatPoppedOut = useSelector((state: State) => state.chat.poppedOut)
const layout = useSelector((state: State) => state.ui.layout)
const overlapHeader = layout.hideSidebar && browser.isElectron && browser.isMac
@@ -45,7 +46,15 @@ export const Header: React.FC = ({ panels = 1 }) => {
const menu = location.pathname.match(REGEX_FIRST_PATH)?.[0]
// Admin pages have two-level roots: /admin/users and /admin/partners (without IDs)
- const adminRootPages = ['/admin/users', '/admin/admins', '/admin/partners', '/admin/enterprise-licenses', '/admin/devices', '/admin/notices', '/partner-stats']
+ const adminRootPages = [
+ '/admin/users',
+ '/admin/admins',
+ '/admin/partners',
+ '/admin/enterprise-licenses',
+ '/admin/devices',
+ '/admin/notices',
+ '/partner-stats',
+ ]
const isAdminRootPage = adminRootPages.includes(location.pathname)
const isRootMenu = menu === location.pathname || isAdminRootPage
@@ -81,7 +90,7 @@ export const Header: React.FC = ({ panels = 1 }) => {
color="grayDarker"
/>
)}
- {MODE === 'development' && (
+ {MODE === 'development' && !chatPoppedOut && (
()({
}
},
/* Complete a sign-in redirect if this page load carries one */
- async handleSignInCallback() {
+ async handleSignInCallback(_: void, state) {
const result = await handleAgentSignInCallback()
if (!result) return
- if (result.ok) dispatch.chat.set({ error: null, open: true })
- else dispatch.chat.set({ error: `Agent sign-in failed — ${result.error}`, open: true })
+ // Don't yank the dock open if the conversation currently lives in the
+ // popout window — the popout is the active surface, not the panel.
+ const openIfDocked = state.chat.poppedOut ? {} : { open: true }
+ if (result.ok) dispatch.chat.set({ error: null, ...openIfDocked })
+ else dispatch.chat.set({ error: `Agent sign-in failed — ${result.error}`, ...openIfDocked })
await dispatch.chat.checkHealth()
},
// Dev fallback: token pasted from the ai-agent harness (devtools:
diff --git a/frontend/src/services/chatPopout.ts b/frontend/src/services/chatPopout.ts
index 8f2c1a452..1f9e933c1 100644
--- a/frontend/src/services/chatPopout.ts
+++ b/frontend/src/services/chatPopout.ts
@@ -58,6 +58,7 @@ const post = (message: PopoutMessage) => channel?.postMessage(message)
let popoutWindow: Window | null = null
let pollTimer: number | undefined
let alivePending = false
+let missedPings = 0
let suppressHandback = false
/* ---------- main-window side ---------- */
@@ -69,9 +70,9 @@ export function openChatPopout(): boolean {
return true
}
-export function initChatPopoutMain(handlers: PopoutMainHandlers): void {
- if (!channel) return
- channel.addEventListener('message', (event: MessageEvent) => {
+export function initChatPopoutMain(handlers: PopoutMainHandlers): () => void {
+ if (!channel) return () => {}
+ const listener = (event: MessageEvent) => {
switch (event.data.type) {
case 'hello':
post({ type: 'adopt', payload: handlers.getHandoff() })
@@ -84,9 +85,12 @@ export function initChatPopoutMain(handlers: PopoutMainHandlers): void {
break
case 'alive':
alivePending = false
+ missedPings = 0
break
}
- })
+ }
+ channel.addEventListener('message', listener)
+ return () => channel.removeEventListener('message', listener)
}
/* Ask whether a popout survives from a previous page load; corrects a stale
@@ -110,13 +114,19 @@ export function checkPopoutPresence(handlers: PopoutMainHandlers): void {
export function broadcastChatSignout(): void {
post({ type: 'signout' })
+ // The popout is being closed deliberately — a lagging poll must not
+ // race in afterward and force `open: true` into freshly-reset state.
+ stopPolling()
}
/* Crash net: a popout that dies without beforeunload still restores the
dock. Uses the window handle when we have one (same page load), pings
- otherwise (main was reloaded while popped out). */
+ otherwise (main was reloaded while popped out). Two consecutive missed
+ replies are required before declaring it lost, so one slow reply doesn't
+ false-positive. */
function startPolling(handlers: PopoutMainHandlers) {
if (pollTimer) return
+ missedPings = 0
pollTimer = window.setInterval(() => {
if (popoutWindow) {
if (popoutWindow.closed) lost(handlers)
@@ -125,7 +135,10 @@ function startPolling(handlers: PopoutMainHandlers) {
alivePending = true
post({ type: 'ping' })
window.setTimeout(() => {
- if (alivePending && pollTimer) lost(handlers)
+ if (alivePending && pollTimer) {
+ missedPings += 1
+ if (missedPings >= 2) lost(handlers)
+ }
}, PRESENCE_TIMEOUT)
}, POLL_INTERVAL)
}
@@ -134,6 +147,7 @@ function stopPolling() {
if (pollTimer) window.clearInterval(pollTimer)
pollTimer = undefined
popoutWindow = null
+ missedPings = 0
}
function lost(handlers: PopoutMainHandlers) {
@@ -143,9 +157,9 @@ function lost(handlers: PopoutMainHandlers) {
/* ---------- popout-window side ---------- */
-export function initChatPopoutWindow(handlers: PopoutWindowHandlers): void {
- if (!channel) return
- channel.addEventListener('message', (event: MessageEvent) => {
+export function initChatPopoutWindow(handlers: PopoutWindowHandlers): () => void {
+ if (!channel) return () => {}
+ const messageListener = (event: MessageEvent) => {
switch (event.data.type) {
case 'adopt':
// Main's copy is authoritative at hand-off; until it arrives the
@@ -160,11 +174,17 @@ export function initChatPopoutWindow(handlers: PopoutWindowHandlers): void {
handlers.onSignout()
break
}
- })
- window.addEventListener('beforeunload', () => {
+ }
+ const beforeUnloadListener = () => {
if (!suppressHandback) post({ type: 'handback', payload: handlers.getHandoff() })
- })
+ }
+ channel.addEventListener('message', messageListener)
+ window.addEventListener('beforeunload', beforeUnloadListener)
post({ type: 'hello' })
+ return () => {
+ channel.removeEventListener('message', messageListener)
+ window.removeEventListener('beforeunload', beforeUnloadListener)
+ }
}
export function popIn(payload: ChatHandoff): void {
From 51eb3398b889709b8c7b9e1a2a50078b17dc37fe Mon Sep 17 00:00:00 2001
From: thelg4
Date: Tue, 28 Jul 2026 12:04:07 -0400
Subject: [PATCH 021/238] feat(chat): name the chat Mycal and heighten the org
selector
Co-Authored-By: Claude Fable 5
---
frontend/src/components/Chat/ChatOrgSelect.tsx | 2 +-
frontend/src/components/Chat/ChatPanel.tsx | 2 +-
frontend/src/components/Chat/ChatWindow.tsx | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/frontend/src/components/Chat/ChatOrgSelect.tsx b/frontend/src/components/Chat/ChatOrgSelect.tsx
index 0181a8dd1..be6bd0c46 100644
--- a/frontend/src/components/Chat/ChatOrgSelect.tsx
+++ b/frontend/src/components/Chat/ChatOrgSelect.tsx
@@ -28,7 +28,7 @@ export const ChatOrgSelect: React.FC = () => {
label="Organization"
value={orgId || userId}
onChange={event => dispatch.chat.set({ orgId: event.target.value })}
- sx={{ '& .MuiSelect-select': { paddingY: '12px' } }}
+ sx={{ '& .MuiSelect-select': { paddingY: '14px' } }}
>
{options.map(o => (
diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx
index 2e48acdce..01c4d854b 100644
--- a/frontend/src/components/Chat/ChatPanel.tsx
+++ b/frontend/src/components/Chat/ChatPanel.tsx
@@ -79,7 +79,7 @@ export const ChatPanel: React.FC = () => {
>
- New Chat
+ Mycal
{!singlePanel && (
{
>
- New Chat
+ Mycal
dispatch.chat.clearConversation()} />
Date: Fri, 31 Jul 2026 19:26:53 -0400
Subject: [PATCH 022/238] feat(chat): builds resolve the agent via
VITE_AGENT_URL, dev stays on the proxy
---
frontend/src/services/agent.ts | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/frontend/src/services/agent.ts b/frontend/src/services/agent.ts
index 5724fc1d9..9bef0dbb8 100644
--- a/frontend/src/services/agent.ts
+++ b/frontend/src/services/agent.ts
@@ -3,9 +3,11 @@
* the client holds the transcript and resends it each turn.
*/
-// Dev: relative path served by the vite proxy (same-origin, CSP-clean).
-// Staging/prod: set VITE_AGENT_URL to the deployed agent service domain.
-export const AGENT_URL = import.meta.env.VITE_AGENT_URL || '/agent'
+// Dev: relative path served by the vite proxy (same-origin, CSP-clean) —
+// always, even when VITE_AGENT_URL is set, so dev stays on the proxy and
+// out of CORS. Builds have no proxy: VITE_AGENT_URL names the deployed
+// agent domain (https — the app's CSP only allows https: connections).
+export const AGENT_URL = import.meta.env.DEV ? '/agent' : import.meta.env.VITE_AGENT_URL || '/agent'
// Hydra credentials for the agent service (AUTH_MODE=hydra), written by the
// in-app sign-in flow (services/hydra.ts) — or a token pasted from the
From bc39422530dc5ef2c29eea0d58b1c69128bfab1b Mon Sep 17 00:00:00 2001
From: thelg4
Date: Fri, 31 Jul 2026 20:00:27 -0400
Subject: [PATCH 023/238] feat(chat): zero-state sign-in prompt matching app
empty-state pattern
---
frontend/src/components/Chat/ChatBody.tsx | 67 +++++++++++-----------
frontend/src/components/Chat/ChatInput.tsx | 5 +-
2 files changed, 36 insertions(+), 36 deletions(-)
diff --git a/frontend/src/components/Chat/ChatBody.tsx b/frontend/src/components/Chat/ChatBody.tsx
index c976d1ae6..7f22927de 100644
--- a/frontend/src/components/Chat/ChatBody.tsx
+++ b/frontend/src/components/Chat/ChatBody.tsx
@@ -1,12 +1,14 @@
import React from 'react'
import { useSelector, useDispatch } from 'react-redux'
-import { Button } from '@mui/material'
+import { Button, Typography } from '@mui/material'
import { State, Dispatch } from '../../store'
import { ChatMessages } from './ChatMessages'
import { ChatApproval } from './ChatApproval'
import { ChatInput } from './ChatInput'
import { ChatOrgSelect } from './ChatOrgSelect'
import { Notice } from '../Notice'
+import { Body } from '../Body'
+import { Icon } from '../Icon'
import { isChatPopout } from '../../services/chatPopout'
/* Everything below the chat header — shared by the docked panel and the
@@ -14,6 +16,7 @@ import { isChatPopout } from '../../services/chatPopout'
export const ChatBody: React.FC = () => {
const chat = useSelector((state: State) => state.chat)
const dispatch = useDispatch()
+ const signedOut = chat.health === 'unauthorized'
return (
<>
@@ -23,42 +26,38 @@ export const ChatBody: React.FC = () => {
Agent unreachable — is the dev service running on :3001?
)}
- {chat.health === 'unauthorized' && (
-
- <>
+ {signedOut ? (
+
+
+
The AI agent needs its own sign-in to act on your behalf.
- {isChatPopout ? (
- ' Sign in from the main app window.'
- ) : (
-
- )}
- >
-
+ {isChatPopout && ' Sign in from the main app window.'}
+
+ {!isChatPopout && (
+
+ )}
+
+ ) : (
+
+ {chat.pendingConfirmation && (
+ dispatch.chat.confirm(approved)}
+ />
+ )}
+ {chat.error && (
+ dispatch.chat.set({ error: null })}>
+ {chat.error}
+
+ )}
+
)}
-
- {chat.pendingConfirmation && (
- dispatch.chat.confirm(approved)}
- />
- )}
- {chat.error && (
- dispatch.chat.set({ error: null })}>
- {chat.error}
-
- )}
-
dispatch.chat.send(text)}
onStop={() => dispatch.chat.stop()}
diff --git a/frontend/src/components/Chat/ChatInput.tsx b/frontend/src/components/Chat/ChatInput.tsx
index eda003026..d714bc751 100644
--- a/frontend/src/components/Chat/ChatInput.tsx
+++ b/frontend/src/components/Chat/ChatInput.tsx
@@ -4,12 +4,13 @@ import { IconButton } from '../../buttons/IconButton'
type Props = {
disabled: boolean
+ placeholder?: string
streaming: boolean
onSend: (text: string) => void
onStop: () => void
}
-export const ChatInput: React.FC = ({ disabled, streaming, onSend, onStop }) => {
+export const ChatInput: React.FC = ({ disabled, placeholder, streaming, onSend, onStop }) => {
const [text, setText] = useState('')
const submit = () => {
const trimmed = text.trim()
@@ -34,7 +35,7 @@ export const ChatInput: React.FC = ({ disabled, streaming, onSend, onStop
fullWidth
multiline
maxRows={6}
- placeholder={disabled ? 'Waiting for approval…' : ''}
+ placeholder={disabled ? placeholder : ''}
value={text}
disabled={disabled}
sx={{ fontSize: 14, paddingY: 0.75 }}
From 62793bc26c919fbff6e20b64a4d2b50f6bdb2985 Mon Sep 17 00:00:00 2001
From: thelg4
Date: Tue, 4 Aug 2026 13:31:32 -0400
Subject: [PATCH 024/238] style(chat): match the org dropdown to the app's
filled select pattern
---
frontend/src/components/Chat/ChatOrgSelect.tsx | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/frontend/src/components/Chat/ChatOrgSelect.tsx b/frontend/src/components/Chat/ChatOrgSelect.tsx
index be6bd0c46..3e5ce61fe 100644
--- a/frontend/src/components/Chat/ChatOrgSelect.tsx
+++ b/frontend/src/components/Chat/ChatOrgSelect.tsx
@@ -24,11 +24,10 @@ export const ChatOrgSelect: React.FC = () => {
dispatch.chat.set({ orgId: event.target.value })}
- sx={{ '& .MuiSelect-select': { paddingY: '14px' } }}
>
{options.map(o => (
From 4bf2cbfb0d243f97ea627f4669eb27aef9d57b9e Mon Sep 17 00:00:00 2001
From: thelg4
Date: Tue, 4 Aug 2026 13:31:32 -0400
Subject: [PATCH 025/238] feat(chat): configurable agent proxy target for
internal testing
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
AGENT_PROXY_TARGET in frontend/.env points the same-origin /agent proxy
at a deployed agent (e.g. http://dev-ai-agent.remote.it) instead of the
local dev service — the proxy hop keeps CSP satisfied while the ALB is
HTTP-only.
---
frontend/vite.config.ts | 13 ++++++++-----
1 file changed, 8 insertions(+), 5 deletions(-)
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 0367f8fbd..6b8695026 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -1,4 +1,4 @@
-import { defineConfig } from 'vite'
+import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
@@ -25,12 +25,15 @@ export default defineConfig(({ mode }) => ({
alias: { '@common': path.resolve(__dirname, '../common/src') },
},
server: {
- // Dev-only: same-origin path to the local ai-agent service, so the app's
- // CSP ('self') passes without loosening. Staging/prod set VITE_AGENT_URL
- // to the deployed agent domain instead — this proxy does not exist in builds.
+ // Dev-only: same-origin path to the ai-agent service, so the app's CSP
+ // ('self') passes without loosening. Defaults to the local dev service;
+ // set AGENT_PROXY_TARGET in frontend/.env to point at a deployed agent
+ // (e.g. http://dev-ai-agent.remote.it — its ALB is HTTP-only for now, so
+ // the same-origin proxy also sidesteps the CSP https:-only rule).
+ // Staging/prod builds set VITE_AGENT_URL instead — no proxy in builds.
proxy: {
'/agent': {
- target: 'http://localhost:3001',
+ target: loadEnv(mode, __dirname, '').AGENT_PROXY_TARGET || 'http://localhost:3001',
changeOrigin: true,
rewrite: p => p.replace(/^\/agent/, ''),
},
From 617275d1ee042419cfb874175a30f170bdcfd61b Mon Sep 17 00:00:00 2001
From: thelg4
Date: Tue, 4 Aug 2026 13:31:32 -0400
Subject: [PATCH 026/238] feat(chat): user-friendly unavailable state
Replaces the dev-facing ':3001' banner: zero-state layout (matching the
sign-in state) when the chat is empty, compact notice above an existing
transcript, and the input disabled while Mycal is unreachable.
---
frontend/src/components/Chat/ChatBody.tsx | 16 +++++++++++++---
1 file changed, 13 insertions(+), 3 deletions(-)
diff --git a/frontend/src/components/Chat/ChatBody.tsx b/frontend/src/components/Chat/ChatBody.tsx
index 7f22927de..99c546036 100644
--- a/frontend/src/components/Chat/ChatBody.tsx
+++ b/frontend/src/components/Chat/ChatBody.tsx
@@ -11,19 +11,22 @@ import { Body } from '../Body'
import { Icon } from '../Icon'
import { isChatPopout } from '../../services/chatPopout'
+const UNAVAILABLE_MESSAGE = 'Mycal is temporarily unavailable. Check your internet connection or try again in a few minutes.'
+
/* Everything below the chat header — shared by the docked panel and the
popout window */
export const ChatBody: React.FC = () => {
const chat = useSelector((state: State) => state.chat)
const dispatch = useDispatch()
const signedOut = chat.health === 'unauthorized'
+ const unreachable = chat.health === 'unreachable'
return (
<>
- {chat.health === 'unreachable' && (
+ {unreachable && !!chat.messages.length && (
- Agent unreachable — is the dev service running on :3001?
+ {UNAVAILABLE_MESSAGE}
)}
{signedOut ? (
@@ -39,6 +42,13 @@ export const ChatBody: React.FC = () => {
)}
+
+
+ {UNAVAILABLE_MESSAGE}
+
+