Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
52 changes: 2 additions & 50 deletions apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ import {
SESSION_WINDOW_MIN_HEIGHT,
SESSION_WINDOW_MIN_WIDTH
} from './session-windows'
import { fanOutSidebarSessions } from './sidebar-session-fanout'
import { ensureSpawnHelperExecutable } from './spawn-helper-perms'
import { createBootstrapCoordinator, sshConfigFingerprint } from './ssh-bootstrap-coordinator'
import { collectSshConfigHosts, parseSshGOutput } from './ssh-config'
Expand Down Expand Up @@ -9307,56 +9308,7 @@ async function interceptSessionRequestForRemote(request) {
return undefined // local fast path → batched endpoint's single DB open
}

const recentsProfile = (searchParams.get('recents_profile') || 'all').trim() || 'all'

const sliceParams = (limitKey, defaultLimit, extra) => {
const sp = new URLSearchParams({
limit: searchParams.get(limitKey) || defaultLimit,
offset: '0',
min_messages: '1',
archived: 'exclude',
order: 'recent',
...extra
})

return sp
}

const recentsSp = sliceParams('recents_limit', '20', { profile: recentsProfile })
const recentsExclude = searchParams.get('recents_exclude')

if (recentsExclude) {
recentsSp.set('exclude_sources', recentsExclude)
}

const cronSp = sliceParams('cron_limit', '50', { profile: 'all', source: 'cron' })

const messagingSp = sliceParams('messaging_limit', '100', { profile: 'all' })
const messagingExclude = searchParams.get('messaging_exclude')

if (messagingExclude) {
messagingSp.set('exclude_sources', messagingExclude)
}

const [recents, cron, messaging] = await Promise.all([
fetchProfilesSessionSlice(recentsSp, remoteProfiles),
fetchProfilesSessionSlice(cronSp, remoteProfiles),
fetchProfilesSessionSlice(messagingSp, remoteProfiles)
])

return {
recents: {
sessions: rowsOf(recents),
total: Number(recents?.total) || 0,
profile_totals: recents?.profile_totals || {}
},
cron: { sessions: rowsOf(cron) },
messaging: {
sessions: rowsOf(messaging),
total: Number(messaging?.total) || rowsOf(messaging).length
},
errors: []
}
return fanOutSidebarSessions(searchParams, remoteProfiles, fetchProfilesSessionSlice)
}

// Per-session read/mutation. Owner is in ?profile= (reads) or request.profile
Expand Down
45 changes: 45 additions & 0 deletions apps/desktop/electron/sidebar-session-fanout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, it, vi } from 'vitest'

import { fanOutSidebarSessions } from './sidebar-session-fanout'

describe('remote sidebar fan-out', () => {
it('honors a concrete cron selector and advertises the capability', async () => {
const calls: URLSearchParams[] = []

const fetchSlice = vi.fn(async (params: URLSearchParams) => {
calls.push(new URLSearchParams(params))

return { sessions: [{ id: params.get('source') === 'cron' ? 'cron' : 'other' }], total: 1 }
})

const result = await fanOutSidebarSessions(
new URLSearchParams({
recents_profile: 'work',
cron_profile: 'work',
cron_limit: '500'
}),
['work'],
fetchSlice
)

expect(calls).toHaveLength(3)
expect(calls[0].get('profile')).toBe('work')
expect(calls[1].get('profile')).toBe('work')
expect(calls[1].get('source')).toBe('cron')
expect(calls[1].get('limit')).toBe('500')
expect(calls[2].get('profile')).toBe('all')
expect(result.capabilities).toEqual({ cron_profile: true })
})

it('preserves All Profiles cron acquisition', async () => {
const calls: URLSearchParams[] = []

await fanOutSidebarSessions(new URLSearchParams({ cron_profile: 'all' }), ['work'], async params => {
calls.push(new URLSearchParams(params))

return { sessions: [] }
})

expect(calls[1].get('profile')).toBe('all')
})
})
77 changes: 77 additions & 0 deletions apps/desktop/electron/sidebar-session-fanout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
interface SidebarSliceResponse {
sessions?: unknown[]
total?: number
profile_totals?: Record<string, number>
}

interface SidebarSessionsResponse {
capabilities: { cron_profile: true }
recents: {
sessions: unknown[]
total: number
profile_totals: Record<string, number>
}
cron: { sessions: unknown[] }
messaging: { sessions: unknown[]; total: number }
errors: unknown[]
}

type FetchSlice = (searchParams: URLSearchParams, remoteProfiles: string[]) => Promise<SidebarSliceResponse>

const rowsOf = (data: SidebarSliceResponse): unknown[] => (Array.isArray(data?.sessions) ? data.sessions : [])

/** Reassemble the batched endpoint through the remote-aware per-slice path. */
export async function fanOutSidebarSessions(
searchParams: URLSearchParams,
remoteProfiles: string[],
fetchSlice: FetchSlice
): Promise<SidebarSessionsResponse> {
const recentsProfile = (searchParams.get('recents_profile') || 'all').trim() || 'all'
const cronProfile = (searchParams.get('cron_profile') || 'all').trim() || 'all'

const sliceParams = (limitKey: string, defaultLimit: string, extra: Record<string, string>) =>
new URLSearchParams({
limit: searchParams.get(limitKey) || defaultLimit,
offset: '0',
min_messages: '1',
archived: 'exclude',
order: 'recent',
...extra
})

const recentsSp = sliceParams('recents_limit', '20', { profile: recentsProfile })
const recentsExclude = searchParams.get('recents_exclude')

if (recentsExclude) {
recentsSp.set('exclude_sources', recentsExclude)
}

const cronSp = sliceParams('cron_limit', '50', { profile: cronProfile, source: 'cron' })
const messagingSp = sliceParams('messaging_limit', '100', { profile: 'all' })
const messagingExclude = searchParams.get('messaging_exclude')

if (messagingExclude) {
messagingSp.set('exclude_sources', messagingExclude)
}

const [recents, cron, messaging] = await Promise.all([
fetchSlice(recentsSp, remoteProfiles),
fetchSlice(cronSp, remoteProfiles),
fetchSlice(messagingSp, remoteProfiles)
])

return {
capabilities: { cron_profile: true },
recents: {
sessions: rowsOf(recents),
total: Number(recents?.total) || 0,
profile_totals: recents?.profile_totals || {}
},
cron: { sessions: rowsOf(cron) },
messaging: {
sessions: rowsOf(messaging),
total: Number(messaging?.total) || rowsOf(messaging).length
},
errors: []
}
}
91 changes: 91 additions & 0 deletions apps/desktop/src/app/chat/sidebar/cron-jobs-section.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { $cronJobsHiddenFromSessions, setCronJobInSessions } from '@/store/cron'
import type { CronJob } from '@/types/hermes'

import { SidebarCronJobsSection } from './cron-jobs-section'

const getCronJobRuns = vi.hoisted(() => vi.fn())

vi.mock('@/hermes', async importOriginal => ({
...(await importOriginal<Record<string, unknown>>()),
getCronJobRuns
}))

const job: CronJob = {
enabled: true,
id: 'daily',
name: 'Daily review',
profile: 'work'
}

describe('Sidebar Cron Jobs Sessions action', () => {
beforeEach(() => {
$cronJobsHiddenFromSessions.set([])
getCronJobRuns.mockReset()
getCronJobRuns.mockResolvedValue([])
})

afterEach(() => {
cleanup()
$cronJobsHiddenFromSessions.set([])
})

it('labels the eye by its next action and forwards the owning profile', () => {
const onSetSessionsVisibility = vi.fn((jobId: string, profile: null | string | undefined, shown: boolean) =>
setCronJobInSessions(jobId, profile, shown)
)

render(
<SidebarCronJobsSection
jobs={[job]}
label="Cron Jobs"
onManageJob={vi.fn()}
onOpenRun={vi.fn()}
onSetSessionsVisibility={onSetSessionsVisibility}
onToggle={vi.fn()}
onTriggerJob={vi.fn()}
open
/>
)

fireEvent.click(screen.getByRole('button', { name: 'Hide from Sessions' }))

expect(screen.getByRole('button', { name: 'Show in Sessions' })).toBeTruthy()
expect(onSetSessionsVisibility).toHaveBeenCalledWith('daily', 'work', false)
})

it('fetches and opens run history with the owning profile', async () => {
const onOpenRun = vi.fn()

getCronJobRuns.mockResolvedValue([
{
id: 'cron_daily_1',
last_active: 0,
profile: 'work',
source: 'cron',
started_at: 0
}
])

render(
<SidebarCronJobsSection
jobs={[job]}
label="Cron Jobs"
onManageJob={vi.fn()}
onOpenRun={onOpenRun}
onSetSessionsVisibility={vi.fn()}
onToggle={vi.fn()}
onTriggerJob={vi.fn()}
open
/>
)

fireEvent.click(screen.getByRole('button', { name: 'Show runs' }))
fireEvent.click(await screen.findByRole('button', { name: '—' }))

expect(getCronJobRuns).toHaveBeenCalledWith('daily', 5, 'work')
expect(onOpenRun).toHaveBeenCalledWith('cron_daily_1', 'work')
})
})
Loading
Loading