Skip to content
Closed
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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ VITE_ALLOWED_HOSTS=nuc,myserver
AGENTBOARD_DB_PATH=~/.agentboard/agentboard.db
AGENTBOARD_INACTIVE_MAX_AGE_HOURS=24
AGENTBOARD_EXCLUDE_PROJECTS=<empty>,/workspace
AGENTBOARD_HOST=blade
AGENTBOARD_REMOTE_HOSTS=mba,carbon,worm
AGENTBOARD_REMOTE_POLL_MS=15000
AGENTBOARD_REMOTE_TIMEOUT_MS=4000
AGENTBOARD_REMOTE_STALE_MS=45000
AGENTBOARD_REMOTE_SSH_OPTS=-o BatchMode=yes -o ConnectTimeout=3
AGENTBOARD_REMOTE_ALLOW_CONTROL=false
```

`HOSTNAME` controls which interfaces the server binds to (default `0.0.0.0` for network access; use `127.0.0.1` for local-only).
Expand All @@ -94,6 +101,16 @@ All persistent data is stored in `~/.agentboard/`: session database (`agentboard

`AGENTBOARD_SKIP_MATCHING_PATTERNS` controls which orphan sessions skip expensive window matching (comma-separated). Defaults: `<codex-exec>` (headless Codex exec sessions), `/private/tmp/*`, `/private/var/folders/*`, `/var/folders/*`, `/tmp/*`. Patterns support trailing `*` for prefix matching. Set to empty string to disable skip matching entirely.

`AGENTBOARD_HOST` sets the host label for local sessions (default: `hostname`).

`AGENTBOARD_REMOTE_HOSTS` enables remote tmux polling over SSH. Provide a comma-separated list of hosts (e.g., `mba,carbon,worm`).

`AGENTBOARD_REMOTE_POLL_MS`, `AGENTBOARD_REMOTE_TIMEOUT_MS`, and `AGENTBOARD_REMOTE_STALE_MS` control remote poll cadence, SSH timeout, and stale host cutoff.

`AGENTBOARD_REMOTE_SSH_OPTS` appends extra SSH options (space-separated).

`AGENTBOARD_REMOTE_ALLOW_CONTROL` is reserved for future remote control support (read-only in MVP).

## Logging

```
Expand Down
22 changes: 16 additions & 6 deletions src/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export default function App() {
)
const setSessions = useSessionStore((state) => state.setSessions)
const setAgentSessions = useSessionStore((state) => state.setAgentSessions)
const setHostStatuses = useSessionStore((state) => state.setHostStatuses)
const updateSession = useSessionStore((state) => state.updateSession)
const setSelectedSessionId = useSessionStore(
(state) => state.setSelectedSessionId
Expand Down Expand Up @@ -65,6 +66,7 @@ export default function App() {
const sidebarWidth = useSettingsStore((state) => state.sidebarWidth)
const setSidebarWidth = useSettingsStore((state) => state.setSidebarWidth)
const projectFilters = useSettingsStore((state) => state.projectFilters)
const hostFilters = useSettingsStore((state) => state.hostFilters)
const soundOnPermission = useSettingsStore((state) => state.soundOnPermission)
const soundOnIdle = useSettingsStore((state) => state.soundOnIdle)

Expand Down Expand Up @@ -159,6 +161,9 @@ export default function App() {

setSessions(message.sessions)
}
if (message.type === 'host-status') {
setHostStatuses(message.hosts)
}
if (message.type === 'session-update') {
// Detect status transitions for sound notifications
// Capture previous status BEFORE updating to ensure we have the old value
Expand Down Expand Up @@ -277,6 +282,7 @@ export default function App() {
setSelectedSessionId,
setSessions,
setAgentSessions,
setHostStatuses,
subscribe,
updateSession,
])
Expand Down Expand Up @@ -312,13 +318,17 @@ export default function App() {
[sessions, sessionSortMode, sessionSortDirection, manualSessionOrder]
)

// Apply project filters to sorted sessions for keyboard navigation
// Apply filters to sorted sessions for keyboard navigation
const filteredSortedSessions = useMemo(() => {
if (projectFilters.length === 0) return sortedSessions
return sortedSessions.filter((session) =>
projectFilters.includes(session.projectPath)
)
}, [sortedSessions, projectFilters])
let next = sortedSessions
if (projectFilters.length > 0) {
next = next.filter((session) => projectFilters.includes(session.projectPath))
}
if (hostFilters.length > 0) {
next = next.filter((session) => hostFilters.includes(session.host ?? ''))
}
return next
}, [sortedSessions, projectFilters, hostFilters])

// Auto-select first visible session when current selection is filtered out
useEffect(() => {
Expand Down
2 changes: 2 additions & 0 deletions src/client/__tests__/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ beforeEach(() => {
showProjectName: true,
showLastUserMessage: true,
showSessionIdPrefix: false,
hostFilters: [],
})

useThemeStore.setState({ theme: 'dark' })
Expand All @@ -190,6 +191,7 @@ afterEach(() => {
showProjectName: true,
showLastUserMessage: true,
showSessionIdPrefix: false,
hostFilters: [],
})
})

Expand Down
2 changes: 2 additions & 0 deletions src/client/__tests__/sessionDrawer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ beforeEach(() => {
showProjectName: true,
showLastUserMessage: true,
showSessionIdPrefix: false,
hostFilters: [],
})
})

Expand All @@ -127,6 +128,7 @@ afterEach(() => {
showProjectName: true,
showLastUserMessage: true,
showSessionIdPrefix: false,
hostFilters: [],
})
})

Expand Down
2 changes: 2 additions & 0 deletions src/client/__tests__/sessionListComponent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ beforeEach(() => {
showLastUserMessage: true,
showSessionIdPrefix: false,
projectFilters: [],
hostFilters: [],
})

useSessionStore.setState({
Expand All @@ -80,6 +81,7 @@ afterEach(() => {
showLastUserMessage: true,
showSessionIdPrefix: false,
projectFilters: [],
hostFilters: [],
})
useSessionStore.setState({
exitingSessions: new Map(),
Expand Down
4 changes: 3 additions & 1 deletion src/client/__tests__/sessionListFilters.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ beforeEach(() => {
showLastUserMessage: true,
showSessionIdPrefix: false,
projectFilters: [],
hostFilters: [],
})

useSessionStore.setState({
Expand All @@ -50,6 +51,7 @@ afterEach(() => {
showLastUserMessage: true,
showSessionIdPrefix: false,
projectFilters: [],
hostFilters: [],
})
useSessionStore.setState({
exitingSessions: new Map(),
Expand All @@ -69,7 +71,7 @@ const baseSession: Session = {

describe('SessionList project filters', () => {
test('marks hidden permission sessions when filters exclude them', () => {
useSettingsStore.setState({ projectFilters: ['/tmp/visible'] })
useSettingsStore.setState({ projectFilters: ['/tmp/visible'], hostFilters: [] })

const sessions: Session[] = [
{ ...baseSession, id: 'visible', projectPath: '/tmp/visible', status: 'working' },
Expand Down
1 change: 1 addition & 0 deletions src/client/__tests__/sessionState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ beforeEach(() => {
showProjectName: true,
showLastUserMessage: true,
showSessionIdPrefix: false,
hostFilters: [],
})
})

Expand Down
2 changes: 2 additions & 0 deletions src/client/__tests__/settingsModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ beforeEach(() => {
showProjectName: true,
showLastUserMessage: true,
showSessionIdPrefix: false,
hostFilters: [],
})
useThemeStore.setState({ theme: 'dark' })
})
Expand All @@ -64,6 +65,7 @@ afterEach(() => {
showProjectName: true,
showLastUserMessage: true,
showSessionIdPrefix: false,
hostFilters: [],
})
useThemeStore.setState({ theme: 'dark' })
})
Expand Down
2 changes: 2 additions & 0 deletions src/client/__tests__/settingsStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ beforeEach(() => {
showLastUserMessage: true,
showSessionIdPrefix: false,
projectFilters: [],
hostFilters: [],
})
})

Expand All @@ -74,6 +75,7 @@ describe('useSettingsStore', () => {
expect(state.lastProjectPath).toBeNull()
expect(state.recentPaths).toEqual([])
expect(state.projectFilters).toEqual([])
expect(state.hostFilters).toEqual([])
})

test('updates default project dir', () => {
Expand Down
20 changes: 20 additions & 0 deletions src/client/components/HostBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { getProjectColorStyle } from '../utils/projectColor'

interface HostBadgeProps {
name: string
className?: string
}

export default function HostBadge({ name, className = '' }: HostBadgeProps) {
const colorStyle = getProjectColorStyle(`host:${name}`)

return (
<span
className={`inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium uppercase leading-none tracking-wide ${className}`}
style={colorStyle}
title={name}
>
{name}
</span>
)
}
133 changes: 133 additions & 0 deletions src/client/components/HostFilterDropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import ChevronDownIcon from '@untitledui-icons/react/line/esm/ChevronDownIcon'
import type { HostStatus } from '@shared/types'

interface HostFilterDropdownProps {
hosts: string[]
selectedHosts: string[]
onSelect: (hosts: string[]) => void
statuses?: HostStatus[]
}

export default function HostFilterDropdown({
hosts,
selectedHosts,
onSelect,
statuses = [],
}: HostFilterDropdownProps) {
const [open, setOpen] = useState(false)
const menuId = useId()
const containerRef = useRef<HTMLDivElement>(null)
const selectedSet = useMemo(() => new Set(selectedHosts), [selectedHosts])
const statusMap = useMemo(
() => new Map(statuses.map((status) => [status.host, status])),
[statuses]
)

const selectedTitle = useMemo(() => {
if (selectedHosts.length === 0) return 'All Hosts'
return selectedHosts.join(', ')
}, [selectedHosts])

const selectedLabel = useMemo(() => {
if (selectedHosts.length === 0) return 'All Hosts'
if (selectedHosts.length === 1) return selectedHosts[0]
return `${selectedHosts.length} hosts`
}, [selectedHosts])

useEffect(() => {
if (!open || typeof document === 'undefined') return
if (!document.addEventListener || !document.removeEventListener) return
const handlePointer = (event: MouseEvent | TouchEvent) => {
const target = event.target as Node | null
if (target && containerRef.current?.contains(target)) return
setOpen(false)
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', handlePointer)
document.addEventListener('touchstart', handlePointer, { passive: true })
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('mousedown', handlePointer)
document.removeEventListener('touchstart', handlePointer)
document.removeEventListener('keydown', handleKeyDown)
}
}, [open])

const toggleHost = (host: string) => {
const next = new Set(selectedSet)
if (next.has(host)) {
next.delete(host)
} else {
next.add(host)
}
const ordered = hosts.filter((value) => next.has(value))
onSelect(ordered)
}

return (
<div ref={containerRef} className="relative">
<button
type="button"
aria-haspopup="menu"
aria-expanded={open}
aria-controls={menuId}
aria-label="Filter by host"
onClick={() => setOpen((value) => !value)}
className="flex h-6 max-w-[9rem] items-center gap-1 rounded border border-border bg-base px-2 text-[11px] text-primary hover:bg-hover focus:border-accent focus:outline-none"
title={selectedTitle}
>
<span className="truncate">{selectedLabel}</span>
<ChevronDownIcon className="h-3 w-3 shrink-0 text-muted" />
</button>
{open && (
<div
id={menuId}
role="menu"
className="absolute right-0 z-20 mt-1 w-48 rounded border border-border bg-surface p-2 text-xs shadow-lg"
>
<label className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-primary hover:bg-hover">
<input
type="checkbox"
checked={selectedHosts.length === 0}
onChange={() => onSelect([])}
className="h-3.5 w-3.5 accent-approval"
/>
<span>All Hosts</span>
</label>
<div className="my-2 h-px bg-border" />
{hosts.length === 0 ? (
<div className="px-2 py-1 text-muted">No hosts</div>
) : (
<div className="max-h-48 overflow-y-auto pr-1">
{hosts.map((host) => {
const status = statusMap.get(host)
const isOffline = status ? !status.ok : false
return (
<label
key={host}
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-primary hover:bg-hover"
title={status?.error ? `${host}: ${status.error}` : host}
>
<input
type="checkbox"
checked={selectedSet.has(host)}
onChange={() => toggleHost(host)}
className="h-3.5 w-3.5 accent-approval"
/>
<span className="truncate">{host}</span>
{isOffline && (
<span className="ml-auto text-[10px] uppercase text-danger">offline</span>
)}
</label>
)
})}
</div>
)}
</div>
)}
</div>
)
}
Loading
Loading