Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
8a7eac9
feat(collaboration): surface pending invitations in the collaboration…
mrveiss Sep 12, 2026
2996c97
feat(collaboration): add an always-visible pending-invitations indica…
mrveiss Sep 12, 2026
4542aae
fix(collaboration): show an inline error when responding to an invita…
mrveiss Sep 12, 2026
701dc89
fix(collaboration): stay under the frontend fragmentation ratchet aft…
mrveiss Sep 16, 2026
fa326fa
fix(hooks): pre-push's targeted vitest run strips the autobot-fronten…
mrveiss Sep 16, 2026
8c5e229
Merge branch 'main' into issue-16470-invitations-ui
mrveiss Sep 17, 2026
0538cbc
fix(chat): the init stops at its first await once the component unmou…
mrveiss Sep 17, 2026
840bfa6
Merge branch 'main' into issue-16274-chat-init-unmount
mrveiss Sep 17, 2026
586eed2
Merge branch 'main' into issue-16470-invitations-ui
mrveiss Sep 17, 2026
da70981
Merge branch 'main' into issue-16274-chat-init-unmount
mrveiss Sep 17, 2026
0d9e3a3
Merge branch 'main' into issue-16274-chat-init-unmount
github-actions[bot] Sep 17, 2026
b5a0c18
Merge branch 'main' into issue-16274-chat-init-unmount
mrveiss Sep 18, 2026
00b6df9
Merge branch 'main' into issue-16470-invitations-ui
mrveiss Sep 18, 2026
77615c1
fix(chat): guard every remaining post-unmount await in chat init (#16…
mrveiss Sep 18, 2026
32bb836
Merge branch 'main' into issue-16470-invitations-ui
mrveiss Sep 18, 2026
35cd9e6
Merge branch 'main' into issue-16274-chat-init-unmount
mrveiss Sep 18, 2026
0550f48
Merge branch 'main' into issue-16470-invitations-ui
mrveiss Sep 18, 2026
11e13d3
chore(vehicle): merge #16472 0550f484f12acf40487c6d0d948541a69ce8374d…
mrveiss Sep 18, 2026
914fde7
chore(vehicle): merge #16911 35cd9e60360869df55193a9583204d8a2abf6f2c…
mrveiss Sep 18, 2026
7cf566b
Merge branch 'main' into vehicle-v090-2026-09-18-chat
mrveiss Sep 18, 2026
02d3c95
Merge branch 'main' into vehicle-v090-2026-09-18-chat
mrveiss Sep 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 165 additions & 7 deletions autobot-frontend/src/components/__tests__/ChatInterface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from '../../test/utils/test-utils'
import { webSocketTestUtil } from '../../test/mocks/websocket-mock'
import { ServiceURLs } from '@/constants/network'
import { useChatStore } from '@/stores/useChatStore'

// #14613/#14842: this file's per-test budget, and only this file's.
//
Expand Down Expand Up @@ -140,14 +141,19 @@ vi.mock('@/config/AppConfig.js', () => ({
}))

// Mock composables that may cause side effects
//
// #16274: a shared object (not a fresh one per call) so tests can assert on
// `mockMessagePoller.start`/`.stop` after render — the component holds its
// own reference, but it's the same instance every time useBackoffPoller() runs.
const mockMessagePoller = {
start: vi.fn(),
stop: vi.fn(),
isCircuitOpen: { value: false },
consecutiveFailures: { value: 0 },
currentInterval: { value: 10000 },
}
vi.mock('@/composables/useBackoffPoller', () => ({
useBackoffPoller: () => ({
start: vi.fn(),
stop: vi.fn(),
isCircuitOpen: { value: false },
consecutiveFailures: { value: 0 },
currentInterval: { value: 10000 },
}),
useBackoffPoller: () => mockMessagePoller,
}))

vi.mock('@/composables/useVoiceOutput', () => ({
Expand Down Expand Up @@ -240,6 +246,24 @@ describe('ChatInterface', () => {
chat_sessions: { data: [] },
system_health: { data: { status: 'healthy' } },
})

// #16274/#3070: vitest.config.ts sets `mockReset: true`, which strips
// vi.fn() implementations before every test — including the ones set
// once at `mockController`'s module-scope literal above. Left unmocked,
// a call like `controller.loadChatSessions().catch(...)` returns
// `undefined` instead of a promise and throws `Cannot read properties of
// undefined (reading 'catch')`, which is exactly the "Chat initialization
// failed" crash this file must never log (see the Error Handling suite).
// Re-applying every default here, not just the one that happened to
// crash, closes the whole landmine class instead of one instance of it.
mockController.loadChatSessions.mockResolvedValue(undefined)
mockController.loadChatMessages.mockResolvedValue(undefined)
mockController.deleteChatSession.mockResolvedValue(undefined)
mockController.getSessionFacts.mockResolvedValue([])
mockController.preserveSessionFacts.mockResolvedValue({})
mockController.pushLocalOnlySessions.mockResolvedValue(undefined)
mockController.sendMessage.mockResolvedValue(undefined)
mockController.clearSession.mockResolvedValue(undefined)
})

afterEach(() => {
Expand Down Expand Up @@ -311,6 +335,126 @@ describe('ChatInterface', () => {
// Sidebar should still render with refresh button available
expect(screen.getByLabelText('chat.sidebar.refreshList')).toBeInTheDocument()
})

// #16274: the init raced a 10s timer that onUnmounted never cancelled, so
// an unmounted component still reached its fallback and wrote to the store.
// In this suite the rejection landed in a LATER test, after `mockReset: true`
// had stripped the mock the fallback calls, surfacing as a crash somewhere
// unrelated while this file stayed green (vitest.config.ts:71, #3070).
//
// Asserted on the fallback never running, not on the timer being cleared:
// clearing the handle is the mechanism, and a future rewrite may cancel the
// init differently. What must stay true is that nothing after an await runs
// once the component is gone.
//
// Rewritten from a version that mocked init to never settle and advanced
// fake timers 11s past unmount, expecting the race's own 10s timer to fire
// it into the fallback. But onUnmounted clears that timer synchronously on
// unmount, before the advance ever runs — so Promise.race never settled,
// the code after it (including the isUnmounted guard under test) was never
// reached, and the assertion below passed whether or not that guard
// existed. Rejecting the mocked init directly, after unmount, is what
// actually drives execution into the guarded catch block.
it('does not run the init fallback after the component unmounts', async () => {
let rejectInit: (reason?: unknown) => void = () => {}
mockInitializeChatInterface.mockImplementation(
() => new Promise((_resolve, reject) => { rejectInit = reject })
)
mockController.loadChatSessions.mockClear()

const { unmount } = renderComponent(ChatInterface, { pinia: true, router: true })
unmount()

// The kind of rejection a real slow/unavailable backend produces —
// after unmount, the way the original 10s race timer used to.
rejectInit(new Error('Simulated backend timeout'))
await waitForUpdate()

expect(mockController.loadChatSessions).not.toHaveBeenCalled()
})

// Review on #16911: pushLocalOnlySessions does real network I/O
// (Promise.allSettled over chatRepository calls) but, unlike the awaits
// immediately before and after it in the same function, wasn't checked
// against isUnmounted before the store write that follows it.
it('does not write to the store when unmounted while pushing local-only sessions', async () => {
let resolvePush: (value?: unknown) => void = () => {}
mockInitializeChatInterface.mockResolvedValue({
chat_sessions: { data: mockChatSessions },
system_health: { data: { status: 'healthy' } },
})
mockController.pushLocalOnlySessions.mockImplementation(
() => new Promise(resolve => { resolvePush = resolve })
)

const { unmount } = renderComponent(ChatInterface, { pinia: true, router: true })
const store = useChatStore()

await waitFor(() => {
expect(mockController.pushLocalOnlySessions).toHaveBeenCalled()
})

unmount()
resolvePush(undefined)
await waitForUpdate()

expect(store.syncSessionsWithBackend).not.toHaveBeenCalled()
})

// Review on #16911: initializeChatInterface() resolves normally even when
// it takes its own isUnmounted early exit, so `await initializeChatInterface()`
// in onMounted always completes — onMounted must check the same flag before
// it resumes, or it re-adds the keydown listener and restarts the message
// poller that onUnmounted already cleaned up moments earlier.
it('does not start polling or add the keydown listener when mount resumes after unmount', async () => {
let resolveInit: (value?: unknown) => void = () => {}
mockInitializeChatInterface.mockImplementation(
() => new Promise(resolve => { resolveInit = resolve })
)
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
mockMessagePoller.start.mockClear()

const { unmount } = renderComponent(ChatInterface, { pinia: true, router: true })
unmount()

// A slow init that eventually succeeds, resolving after the component
// is already gone — the same race initializeChatInterface() itself
// guards against, one call frame up.
resolveInit({
chat_sessions: { data: [] },
system_health: { data: { status: 'healthy' } },
})
await waitForUpdate()

expect(addEventListenerSpy).not.toHaveBeenCalledWith('keydown', expect.any(Function))
expect(mockMessagePoller.start).not.toHaveBeenCalled()

addEventListenerSpy.mockRestore()
})

// Review on #16911: onUnmounted's own cleanup — verified directly, since
// the tests above only ever exercise the path where mount never finishes.
it('removes the keydown listener and stops the poller on a normal unmount', async () => {
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener')
mockMessagePoller.stop.mockClear()

const { unmount } = renderComponent(ChatInterface, { pinia: true, router: true })

// Waits for the poller to start rather than just for init to be called:
// onMounted adds the keydown listener in the same synchronous slice as
// starting the poller (no await between them), so this also guarantees
// the listener registration this test unmounts past has already run.
await waitFor(() => {
expect(mockMessagePoller.start).toHaveBeenCalled()
})

unmount()

expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function))
expect(mockMessagePoller.stop).toHaveBeenCalled()

removeEventListenerSpy.mockRestore()
})
})

describe('Chat Management', () => {
Expand Down Expand Up @@ -521,13 +665,27 @@ describe('ChatInterface', () => {
it('handles API errors gracefully', async () => {
// Make initialization fail
mockInitializeChatInterface.mockRejectedValue(new Error('Network error'))
const errorSpy = vi.spyOn(console, 'error')

const { container } = renderComponent(ChatInterface, { pinia: true, router: true })

// Component should render despite error
await waitFor(() => {
expect(container).toBeInTheDocument()
})

// #16274 AC3: this test used to crash the fallback it exercises —
// mockController.loadChatSessions had its module-scope mock stripped
// by `mockReset: true` before this test ran and was never re-applied
// per-test, so `controller.loadChatSessions().catch(...)` called
// `.catch` on `undefined` and threw, surfacing here as "Chat
// initialization failed" (fixed in this file's beforeEach).
const loggedInitFailed = errorSpy.mock.calls.some(args =>
args.some(arg => typeof arg === 'string' && arg.includes('Chat initialization failed'))
)
expect(loggedInitFailed).toBe(false)

errorSpy.mockRestore()
})

it('handles empty chat history response', async () => {
Expand Down
18 changes: 15 additions & 3 deletions autobot-frontend/src/components/chat/ChatCollaborationPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@
<span v-if="tab.id === 'notifications' && secretNotifications.length > 0" class="ml-1 text-electric-500">
({{ secretNotifications.length }})
</span>
<span v-if="tab.id === 'invitations' && pendingInvitations.length > 0" class="ml-1 text-electric-500">
({{ pendingInvitations.length }})
</span>
</button>
</div>

Expand All @@ -57,6 +60,7 @@
/>
<ActivityFeed v-else-if="activeTab === 'activity'" />
<SecretNotifications v-else-if="activeTab === 'notifications'" />
<PendingInvitations v-else-if="activeTab === 'invitations'" />
</div>

<InviteUserDialog
Expand All @@ -76,13 +80,20 @@
* mirroring ChatFilePanel's existing pattern. Shown only for a
* collaborative session (session.mode === 'collaborative'), gated by the
* parent that mounts this panel.
*
* #16470: added the Invitations tab (PendingInvitations.vue). Note
* `pendingInvitations` itself is NOT scoped to the panel's current
* session -- it lists invitations to every session addressed to the
* current user, so this tab is reachable (and useful) even though the
* panel that hosts it only opens for an already-collaborative session.
*/
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Icon from '@/components/ui/Icon.vue'
import ParticipantList from '@/components/collaboration/ParticipantList.vue'
import ActivityFeed from '@/components/collaboration/ActivityFeed.vue'
import SecretNotifications from '@/components/collaboration/SecretNotifications.vue'
import PendingInvitations from '@/components/collaboration/PendingInvitations.vue'
import InviteUserDialog from '@/components/collaboration/InviteUserDialog.vue'
import { useSessionCollaboration } from '@/composables/useSessionCollaboration'
import { apiService } from '@/services/api'
Expand All @@ -92,19 +103,20 @@ import { createLogger } from '@/utils/debugUtils'
const logger = createLogger('ChatCollaborationPanel')
const { t } = useI18n()
const chatStore = useChatStore()
const { secretNotifications } = useSessionCollaboration()
const { secretNotifications, pendingInvitations } = useSessionCollaboration()

defineEmits<{
close: []
}>()

const activeTab = ref<'participants' | 'activity' | 'notifications'>('participants')
const activeTab = ref<'participants' | 'activity' | 'notifications' | 'invitations'>('participants')
const showInviteDialog = ref(false)

const tabs = [
{ id: 'participants' as const, get label() { return t('collaboration.panel.tabParticipants') } },
{ id: 'activity' as const, get label() { return t('collaboration.panel.tabActivity') } },
{ id: 'notifications' as const, get label() { return t('collaboration.panel.tabNotifications') } }
{ id: 'notifications' as const, get label() { return t('collaboration.panel.tabNotifications') } },
{ id: 'invitations' as const, get label() { return t('collaboration.panel.tabInvitations') } }
]

const handleInvited = (_userId: string, username: string) => {
Expand Down
49 changes: 48 additions & 1 deletion autobot-frontend/src/components/chat/ChatInterface.vue
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@
>
<Icon name="users" />
</button>
<!-- #16470 follow-up: always-mountable, independent of session
mode -- an invitee usually has no collaborative session of
their own yet, so this can't be gated the same way. -->
<PendingInvitationsBell />
</template>
</ChatHeader>

Expand Down Expand Up @@ -318,6 +322,7 @@ import ChatTabs from './ChatTabs.vue'
import ChatTabContent from './ChatTabContent.vue'
import ChatFilePanel from './ChatFilePanel.vue'
import ChatCollaborationPanel from './ChatCollaborationPanel.vue'
import PendingInvitationsBell from '@/components/collaboration/PendingInvitationsBell.vue'
import KnowledgePersistenceDialog from '@/components/knowledge/KnowledgePersistenceDialog.vue'
import CommandPermissionDialog from '@/components/ui/CommandPermissionDialog.vue'
import WorkflowProgressWidget from '@/components/workflow/WorkflowProgressWidget.vue'
Expand Down Expand Up @@ -1070,6 +1075,16 @@ const handleKeyboardShortcuts = (event: KeyboardEvent) => {

// STREAMLINED: Simplified initialization without complex timeout racing
// Issue #671: Added initialization state tracking for loading feedback
// #16274: the init outlives the component without these. The 10s race timer
// below was never cleared — not when it lost the race, and not on unmount — so
// every successful mount left a pending timer that rejected 10s later, ran the
// fallback, and wrote to the store of a component that no longer exists. In
// tests that landed in a LATER test, after `mockReset: true` had stripped the
// mock the fallback depends on, which is why it surfaced as an unrelated
// failure rather than here (vitest.config.ts:71, #3070).
let initTimeoutHandle: ReturnType<typeof setTimeout> | null = null
let isUnmounted = false

const initializeChatInterface = async () => {
// Issue #671: Set initializing state to show loading indicator
store.setInitializing(true)
Expand All @@ -1082,12 +1097,14 @@ const initializeChatInterface = async () => {
// renders because BatchApiService uses Promise.allSettled.
const loadPromise = batchApiService.initializeChatInterface()
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('Initialization timeout')), 10000)
initTimeoutHandle = setTimeout(() => reject(new Error('Initialization timeout')), 10000)
})

try {
// Race initialization with timeout
const data = await Promise.race([loadPromise, timeoutPromise])
// #16274: nothing after this await may touch the store once unmounted.
if (isUnmounted) return

// Process results - sync with backend (source of truth)
// Explicitly check for error to distinguish API failures from empty responses
Expand All @@ -1098,6 +1115,10 @@ const initializeChatInterface = async () => {
// pushed (empty placeholders are skipped).
const backendIds = new Set<string>((sessions as Array<{ id: string }>).map(s => s.id))
await controller.pushLocalOnlySessions(backendIds)
// #16274: same rule as the Promise.race guard above — this await does
// real network I/O, and a component that unmounted while it was in
// flight must not write to the store below.
if (isUnmounted) return
// Issue #4352: intentional_empty=true means the backend confirmed 0 sessions
// is correct (user deleted all). Pass this through so syncSessionsWithBackend
// can bypass the #4328 defensive guard and clear local sessions as intended.
Expand Down Expand Up @@ -1126,14 +1147,26 @@ const initializeChatInterface = async () => {
logger.debug('⏱️ Initialization timed out, using fallback:',
error instanceof Error ? error.message : error)

// #16274: the fallback is the path that crashed a later test. An init
// that has lost its component must not retry on its behalf.
if (isUnmounted) return

// Fallback to individual loading (this is the chat-history retry path).
if (store.sessions.length === 0) {
await controller.loadChatSessions().catch((err) =>
logger.debug('Fallback chat session load failed:', err instanceof Error ? err.message : err))
}
if (isUnmounted) return

// Issue #671: Clear initialization state after fallback attempt
store.setInitializing(false)
} finally {
// #16274: cleared whether the init won the race or lost it. Previously
// only the losing timer ever stopped mattering, and it stopped by firing.
if (initTimeoutHandle !== null) {
clearTimeout(initTimeoutHandle)
initTimeoutHandle = null
}
}

logger.debug('✅ Chat interface initialization completed')
Expand All @@ -1160,9 +1193,15 @@ function _onLgBreakpoint(e: MediaQueryListEvent): void {
onMounted(async () => {
// Initialize chat interface with streamlined loading
await initializeChatInterface()
// #16274: initializeChatInterface() guards its own internals but still
// resolves normally on an early return — this await always completes, so
// onMounted must check the same flag before resuming, or it re-adds the
// listener/poller that onUnmounted already cleaned up.
if (isUnmounted) return

// Load NoVNC URL after initialization
await loadNovncUrl()
if (isUnmounted) return

// #6773: connection state mirrors appStore.backendStatus (driven by
// HealthMonitor). Seed once from current store state so initial
Expand All @@ -1183,6 +1222,14 @@ onMounted(async () => {
})

onUnmounted(() => {
// #16274: stop the in-flight init before anything else — the awaits inside it
// check this flag, and the race timer must not outlive the component.
isUnmounted = true
if (initTimeoutHandle !== null) {
clearTimeout(initTimeoutHandle)
initTimeoutHandle = null
}

// Clean up event listeners
document.removeEventListener('keydown', handleKeyboardShortcuts)
_lgMediaQuery?.removeEventListener('change', _onLgBreakpoint)
Expand Down
Loading
Loading