feat: live session monitoring with busy/idle indicators and sound notification
Detect active Claude sessions' real-time status by monitoring JSONL file modification times. Shows green dot when Claude is processing, yellow dot with elapsed time when idle. Plays Glass.aiff when sessions transition from busy to idle. Updates website and README with new features. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -64,6 +64,9 @@ export async function discoverProjects(): Promise<Project[]> {
|
||||
sessionCount,
|
||||
totalMessages: info.msgs,
|
||||
tags: getTags(path),
|
||||
activeSessions: 0,
|
||||
busySessions: 0,
|
||||
lastActivityMs: 0,
|
||||
expanded: false,
|
||||
sessions: null,
|
||||
branches: null,
|
||||
|
||||
@@ -21,6 +21,9 @@ export function generateMockProjects(): Project[] {
|
||||
sessionCount: 14,
|
||||
totalMessages: 342,
|
||||
tags: "ts bun hono",
|
||||
activeSessions: 0,
|
||||
busySessions: 0,
|
||||
lastActivityMs: 0,
|
||||
expanded: false,
|
||||
sessions: null,
|
||||
branches: null,
|
||||
@@ -40,6 +43,9 @@ export function generateMockProjects(): Project[] {
|
||||
sessionCount: 8,
|
||||
totalMessages: 187,
|
||||
tags: "ts react vite",
|
||||
activeSessions: 0,
|
||||
busySessions: 0,
|
||||
lastActivityMs: 0,
|
||||
expanded: false,
|
||||
sessions: null,
|
||||
branches: null,
|
||||
@@ -59,6 +65,9 @@ export function generateMockProjects(): Project[] {
|
||||
sessionCount: 22,
|
||||
totalMessages: 891,
|
||||
tags: "rust wgpu",
|
||||
activeSessions: 0,
|
||||
busySessions: 0,
|
||||
lastActivityMs: 0,
|
||||
expanded: false,
|
||||
sessions: null,
|
||||
branches: null,
|
||||
@@ -78,6 +87,9 @@ export function generateMockProjects(): Project[] {
|
||||
sessionCount: 5,
|
||||
totalMessages: 78,
|
||||
tags: "go docker k8s",
|
||||
activeSessions: 0,
|
||||
busySessions: 0,
|
||||
lastActivityMs: 0,
|
||||
expanded: false,
|
||||
sessions: null,
|
||||
branches: null,
|
||||
@@ -97,6 +109,9 @@ export function generateMockProjects(): Project[] {
|
||||
sessionCount: 11,
|
||||
totalMessages: 256,
|
||||
tags: "ts rn expo",
|
||||
activeSessions: 0,
|
||||
busySessions: 0,
|
||||
lastActivityMs: 0,
|
||||
expanded: false,
|
||||
sessions: null,
|
||||
branches: null,
|
||||
@@ -116,6 +131,9 @@ export function generateMockProjects(): Project[] {
|
||||
sessionCount: 3,
|
||||
totalMessages: 45,
|
||||
tags: "ts astro mdx",
|
||||
activeSessions: 0,
|
||||
busySessions: 0,
|
||||
lastActivityMs: 0,
|
||||
expanded: false,
|
||||
sessions: null,
|
||||
branches: null,
|
||||
@@ -135,6 +153,9 @@ export function generateMockProjects(): Project[] {
|
||||
sessionCount: 19,
|
||||
totalMessages: 523,
|
||||
tags: "py torch",
|
||||
activeSessions: 0,
|
||||
busySessions: 0,
|
||||
lastActivityMs: 0,
|
||||
expanded: false,
|
||||
sessions: null,
|
||||
branches: null,
|
||||
@@ -154,6 +175,9 @@ export function generateMockProjects(): Project[] {
|
||||
sessionCount: 2,
|
||||
totalMessages: 31,
|
||||
tags: "ts express pg",
|
||||
activeSessions: 0,
|
||||
busySessions: 0,
|
||||
lastActivityMs: 0,
|
||||
expanded: false,
|
||||
sessions: null,
|
||||
branches: null,
|
||||
@@ -399,6 +423,22 @@ const mockBranchData: Record<string, BranchInfo[]> = {
|
||||
],
|
||||
}
|
||||
|
||||
export function generateMockBusySessions(projects: Project[]): void {
|
||||
const now = Date.now()
|
||||
for (const p of projects) {
|
||||
if (p.activeSessions > 0) {
|
||||
const isBusy = Math.random() > 0.4
|
||||
p.busySessions = isBusy ? Math.min(p.activeSessions, 1 + Math.floor(Math.random() * p.activeSessions)) : 0
|
||||
p.lastActivityMs = isBusy
|
||||
? now - Math.floor(Math.random() * 3000)
|
||||
: now - (10_000 + Math.floor(Math.random() * 600_000))
|
||||
} else {
|
||||
p.busySessions = 0
|
||||
p.lastActivityMs = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function generateMockSessions(projectPath: string): SessionInfo[] {
|
||||
const name = projectPath.split("/").pop() || ""
|
||||
return mockSessionData[name] || []
|
||||
|
||||
226
src/data/monitor.ts
Normal file
226
src/data/monitor.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { readdirSync, statSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import type { Project } from "../lib/types"
|
||||
|
||||
const PROJECTS_DIR = `${Bun.env.HOME}/.claude/projects`
|
||||
const BUSY_THRESHOLD_MS = 5000
|
||||
|
||||
export interface ActiveSession {
|
||||
pid: string
|
||||
cwd: string
|
||||
tty: string
|
||||
sessionFile: string | null
|
||||
busy: boolean
|
||||
lastActivityMs: number
|
||||
}
|
||||
|
||||
// path → list of active sessions with tty info
|
||||
const sessionsByPath = new Map<string, ActiveSession[]>()
|
||||
|
||||
function cwdToProjectKey(cwd: string): string {
|
||||
return cwd.replaceAll("/", "-")
|
||||
}
|
||||
|
||||
function findActiveJsonl(projectKey: string): { path: string; mtime: number } | null {
|
||||
const projDir = join(PROJECTS_DIR, projectKey)
|
||||
try {
|
||||
const files = readdirSync(projDir).filter(f => f.endsWith(".jsonl"))
|
||||
let best: { path: string; mtime: number } | null = null
|
||||
for (const f of files) {
|
||||
const full = join(projDir, f)
|
||||
try {
|
||||
const st = statSync(full)
|
||||
const mt = st.mtimeMs
|
||||
if (!best || mt > best.mtime) best = { path: full, mtime: mt }
|
||||
} catch {}
|
||||
}
|
||||
return best
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function detectActiveSessions(): Promise<Map<string, number>> {
|
||||
const result = new Map<string, number>()
|
||||
sessionsByPath.clear()
|
||||
|
||||
let pids: string[]
|
||||
try {
|
||||
const proc = Bun.spawn(["pgrep", "-f", "^claude"], {
|
||||
stdout: "pipe",
|
||||
stderr: "ignore",
|
||||
})
|
||||
const text = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
pids = text.trim().split("\n").filter(Boolean)
|
||||
} catch {
|
||||
return result
|
||||
}
|
||||
|
||||
if (pids.length === 0) return result
|
||||
|
||||
const infoPromises = pids.map(async (pid): Promise<ActiveSession | null> => {
|
||||
try {
|
||||
const proc = Bun.spawn(["lsof", "-p", pid, "-a", "-d", "cwd,0", "-F", "nf"], {
|
||||
stdout: "pipe",
|
||||
stderr: "ignore",
|
||||
})
|
||||
const text = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
|
||||
let cwd = ""
|
||||
let tty = ""
|
||||
let currentFd = ""
|
||||
|
||||
for (const line of text.split("\n")) {
|
||||
if (line.startsWith("f")) {
|
||||
currentFd = line.slice(1)
|
||||
} else if (line.startsWith("n") && line.length > 1) {
|
||||
const val = line.slice(1)
|
||||
if (currentFd === "cwd") cwd = val
|
||||
else if (currentFd === "0" && val.startsWith("/dev/")) tty = val
|
||||
}
|
||||
}
|
||||
|
||||
if (cwd) {
|
||||
const key = cwdToProjectKey(cwd)
|
||||
const jsonl = findActiveJsonl(key)
|
||||
const now = Date.now()
|
||||
const busy = jsonl ? (now - jsonl.mtime) < BUSY_THRESHOLD_MS : false
|
||||
|
||||
return { pid, cwd, tty, sessionFile: jsonl?.path ?? null, busy, lastActivityMs: jsonl?.mtime ?? 0 }
|
||||
}
|
||||
} catch {}
|
||||
return null
|
||||
})
|
||||
|
||||
const infos = await Promise.all(infoPromises)
|
||||
for (const info of infos) {
|
||||
if (!info) continue
|
||||
result.set(info.cwd, (result.get(info.cwd) || 0) + 1)
|
||||
if (!sessionsByPath.has(info.cwd)) sessionsByPath.set(info.cwd, [])
|
||||
sessionsByPath.get(info.cwd)!.push(info)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function getSessionTtys(projectPath: string): string[] {
|
||||
const sessions = sessionsByPath.get(projectPath)
|
||||
if (!sessions) return []
|
||||
return sessions.map(s => s.tty).filter(Boolean)
|
||||
}
|
||||
|
||||
export function getBusyCount(projectPath: string): number {
|
||||
const sessions = sessionsByPath.get(projectPath)
|
||||
if (!sessions) return 0
|
||||
return sessions.filter(s => s.busy).length
|
||||
}
|
||||
|
||||
export function getLastActivityMs(projectPath: string): number {
|
||||
const sessions = sessionsByPath.get(projectPath)
|
||||
if (!sessions) return 0
|
||||
let best = 0
|
||||
for (const s of sessions) {
|
||||
if (s.lastActivityMs > best) best = s.lastActivityMs
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
export async function focusTerminalByPath(projectPath: string): Promise<boolean> {
|
||||
const ttys = getSessionTtys(projectPath)
|
||||
if (ttys.length === 0) return false
|
||||
|
||||
const tty = ttys[0]
|
||||
const script = `
|
||||
tell application "Terminal"
|
||||
activate
|
||||
repeat with w in windows
|
||||
repeat with t in tabs of w
|
||||
if tty of t is "${tty}" then
|
||||
set selected of t to true
|
||||
set index of w to 1
|
||||
return true
|
||||
end if
|
||||
end repeat
|
||||
end repeat
|
||||
end tell
|
||||
return false`
|
||||
|
||||
try {
|
||||
const proc = Bun.spawn(["osascript", "-e", script], {
|
||||
stdout: "pipe",
|
||||
stderr: "ignore",
|
||||
})
|
||||
const out = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
const focused = out.trim() === "true"
|
||||
|
||||
if (focused) {
|
||||
const ttys = getSessionTtys(projectPath)
|
||||
for (const tty of ttys) {
|
||||
try {
|
||||
await Bun.write(tty, "\x07")
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
return focused
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function updateProjectSessions(projects: Project[], sessions: Map<string, number>): boolean {
|
||||
let changed = false
|
||||
for (const project of projects) {
|
||||
const count = sessions.get(project.path) || 0
|
||||
const busy = getBusyCount(project.path)
|
||||
const activity = getLastActivityMs(project.path)
|
||||
if (project.activeSessions !== count || project.busySessions !== busy || project.lastActivityMs !== activity) {
|
||||
project.activeSessions = count
|
||||
project.busySessions = busy
|
||||
project.lastActivityMs = activity
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
export function checkTransitions(
|
||||
projects: Project[],
|
||||
prevBusy: Map<string, number>
|
||||
): string[] {
|
||||
const transitioned: string[] = []
|
||||
for (const project of projects) {
|
||||
const prev = prevBusy.get(project.path) || 0
|
||||
if (prev > 0 && project.busySessions === 0 && project.activeSessions > 0) {
|
||||
transitioned.push(project.name)
|
||||
}
|
||||
}
|
||||
return transitioned
|
||||
}
|
||||
|
||||
export function snapshotBusy(projects: Project[]): Map<string, number> {
|
||||
const snap = new Map<string, number>()
|
||||
for (const p of projects) {
|
||||
snap.set(p.path, p.busySessions)
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
export function playDoneSound(): void {
|
||||
Bun.spawn(["afplay", "/System/Library/Sounds/Glass.aiff"], {
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
}
|
||||
|
||||
export function generateMockActiveSessions(projects: Project[]): void {
|
||||
const indices = Array.from(projects.keys())
|
||||
const shuffled = indices.sort(() => Math.random() - 0.5)
|
||||
const activeCount = Math.min(3 + Math.floor(Math.random() * 2), projects.length)
|
||||
for (let i = 0; i < activeCount; i++) {
|
||||
projects[shuffled[i]].activeSessions = 1 + Math.floor(Math.random() * 2)
|
||||
}
|
||||
}
|
||||
105
src/index.ts
105
src/index.ts
@@ -20,13 +20,15 @@ import {
|
||||
import { discoverProjects } from "./data/history"
|
||||
import { loadGitMetadata, loadBranches } from "./data/git"
|
||||
import { loadSessions } from "./data/sessions"
|
||||
import { generateMockProjects, generateMockSessions, generateMockBranches } from "./data/mock"
|
||||
import { generateMockProjects, generateMockSessions, generateMockBranches, generateMockBusySessions } from "./data/mock"
|
||||
import { detectActiveSessions, updateProjectSessions, generateMockActiveSessions, focusTerminalByPath, checkTransitions, snapshotBusy, playDoneSound } from "./data/monitor"
|
||||
import { launchSelections } from "./actions/launcher"
|
||||
import type { Project, DisplayRow } from "./lib/types"
|
||||
import { timeAgo, formatSize } from "./lib/time"
|
||||
import { timeAgo, formatSize, elapsedCompact } from "./lib/time"
|
||||
|
||||
// ─── Theme ──────────────────────────────────────────────────────────
|
||||
const CURSOR_BG = "#283457"
|
||||
const ACTIVE_BG = "#1a2e1a"
|
||||
const ACCENT = "#7aa2f7"
|
||||
const DIM_CLR = "#565f89"
|
||||
|
||||
@@ -41,6 +43,8 @@ let sortMode = 0
|
||||
const sortLabels = ["recent", "name", "commit", "sessions"]
|
||||
let sortedIndices: number[] = []
|
||||
let displayRows: DisplayRow[] = []
|
||||
let monitorInterval: ReturnType<typeof setInterval> | null = null
|
||||
let prevBusySnapshot: Map<string, number> = new Map()
|
||||
|
||||
// ─── UI Refs ────────────────────────────────────────────────────────
|
||||
let renderer: CliRenderer
|
||||
@@ -111,6 +115,21 @@ function fmtSyncIndicator(ahead: number, behind: number): string {
|
||||
}
|
||||
|
||||
function fmtProjectRow(project: Project, isSelected: boolean) {
|
||||
let activeDot: string
|
||||
let activeTag: string
|
||||
if (project.activeSessions > 0) {
|
||||
if (project.busySessions > 0) {
|
||||
activeDot = green("●")
|
||||
activeTag = project.activeSessions > 1 ? yellow(String(project.activeSessions)) : " "
|
||||
} else {
|
||||
activeDot = yellow("◉")
|
||||
const elapsed = elapsedCompact(project.lastActivityMs)
|
||||
activeTag = elapsed ? dim(elapsed.padEnd(2).slice(0, 2)) : " "
|
||||
}
|
||||
} else {
|
||||
activeDot = dim("○")
|
||||
activeTag = " "
|
||||
}
|
||||
const check = isSelected ? green("✓") : " "
|
||||
const arrow = project.expanded ? "▼" : "▶"
|
||||
const name =
|
||||
@@ -137,7 +156,7 @@ function fmtProjectRow(project: Project, isSelected: boolean) {
|
||||
else if (ca.includes("d ago")) claudeCol = green(ca.padEnd(9))
|
||||
else claudeCol = dim(ca.padEnd(9))
|
||||
|
||||
return t` [${check}] ${dim(arrow)} ${name.padEnd(28)} ${magenta(branch.padEnd(9))}${syncCol}${dim(
|
||||
return t` ${activeDot}${activeTag}[${check}] ${dim(arrow)} ${name.padEnd(28)} ${magenta(branch.padEnd(9))}${syncCol}${dim(
|
||||
(project.commitAge || "-").padEnd(10)
|
||||
)}${(project.commitMsg || "-").padEnd(22)}${dirtyCol}${claudeCol}${dim(
|
||||
String(project.sessionCount).padStart(3)
|
||||
@@ -204,9 +223,14 @@ function updateHeader() {
|
||||
const total = selectedProjects.size + selectedSessions.size
|
||||
const branchNote = selectedBranches.size > 0 ? ` (${selectedBranches.size} branch switch)` : ""
|
||||
const modeLabel = demoMode ? " [DEMO]" : ""
|
||||
const activeCount = projects.reduce((sum, p) => sum + (p.activeSessions > 0 ? 1 : 0), 0)
|
||||
const busyCount = projects.reduce((sum, p) => sum + (p.busySessions > 0 ? 1 : 0), 0)
|
||||
const activeLabel = activeCount > 0
|
||||
? ` │ ${green(`${busyCount} busy`)} ${yellow(`${activeCount - busyCount} idle`)}`
|
||||
: ""
|
||||
headerText.content = t` ${bold("cladm")}${yellow(modeLabel)} — ${String(total)} selected${branchNote} ${dim(
|
||||
`sort: ${sortLabels[sortMode]} │ ${projects.length} projects`
|
||||
)}`
|
||||
)}${activeLabel}`
|
||||
}
|
||||
|
||||
function updateColumnHeaders() {
|
||||
@@ -216,7 +240,7 @@ function updateColumnHeaders() {
|
||||
|
||||
function updateFooter() {
|
||||
footerText.content = t` ${dim(
|
||||
"↑↓ nav │ space select │ → expand │ ← collapse │ a all │ n none │ s sort │ enter launch │ q quit"
|
||||
"↑↓ nav │ space select │ → expand │ ← collapse │ f folder │ g go to │ a all │ n none │ s sort │ enter launch │ q quit"
|
||||
)}`
|
||||
}
|
||||
|
||||
@@ -291,11 +315,14 @@ function rebuildList() {
|
||||
content = fmtNewSessionRow(row.projectIndex, isSel)
|
||||
}
|
||||
|
||||
if (isCursor) {
|
||||
const isActive = row.type === "project" && project.activeSessions > 0
|
||||
const bgColor = isCursor ? CURSOR_BG : isActive ? ACTIVE_BG : undefined
|
||||
|
||||
if (bgColor) {
|
||||
listBox.add(
|
||||
Box(
|
||||
{
|
||||
backgroundColor: CURSOR_BG,
|
||||
backgroundColor: bgColor,
|
||||
shouldFill: true,
|
||||
width: "100%",
|
||||
height: rowHeight,
|
||||
@@ -423,6 +450,22 @@ function handleKeypress(key: KeyEvent) {
|
||||
break
|
||||
}
|
||||
|
||||
case "f": {
|
||||
const row = displayRows[cursor]
|
||||
const project = projects[row.projectIndex]
|
||||
Bun.spawn(["open", project.path])
|
||||
break
|
||||
}
|
||||
|
||||
case "g": {
|
||||
const row = displayRows[cursor]
|
||||
const project = projects[row.projectIndex]
|
||||
if (project.activeSessions > 0) {
|
||||
focusTerminalByPath(project.path)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
case "a":
|
||||
for (const p of projects) selectedProjects.add(p.path)
|
||||
break
|
||||
@@ -439,12 +482,25 @@ function handleKeypress(key: KeyEvent) {
|
||||
cursor = 0
|
||||
break
|
||||
|
||||
case "return":
|
||||
case "return": {
|
||||
// If cursor is on a project row with active session and nothing selected, focus it
|
||||
const returnRow = displayRows[cursor]
|
||||
if (
|
||||
returnRow.type === "project" &&
|
||||
projects[returnRow.projectIndex].activeSessions > 0 &&
|
||||
selectedProjects.size === 0 &&
|
||||
selectedSessions.size === 0
|
||||
) {
|
||||
focusTerminalByPath(projects[returnRow.projectIndex].path)
|
||||
return
|
||||
}
|
||||
doLaunch()
|
||||
return
|
||||
}
|
||||
|
||||
case "q":
|
||||
case "escape":
|
||||
if (monitorInterval) clearInterval(monitorInterval)
|
||||
renderer.destroy()
|
||||
return
|
||||
|
||||
@@ -489,6 +545,7 @@ async function expandProject(projectIndex: number) {
|
||||
|
||||
async function doLaunch() {
|
||||
if (selectedProjects.size === 0 && selectedSessions.size === 0) return
|
||||
if (monitorInterval) clearInterval(monitorInterval)
|
||||
if (demoMode) {
|
||||
const total = selectedProjects.size + selectedSessions.size
|
||||
renderer.destroy()
|
||||
@@ -598,6 +655,38 @@ async function main() {
|
||||
updateFooter()
|
||||
|
||||
renderer.keyInput.on("keypress", handleKeypress)
|
||||
|
||||
// Live session monitoring
|
||||
if (demoMode) {
|
||||
generateMockActiveSessions(projects)
|
||||
generateMockBusySessions(projects)
|
||||
prevBusySnapshot = snapshotBusy(projects)
|
||||
updateAll()
|
||||
} else {
|
||||
detectActiveSessions().then((sessions) => {
|
||||
if (updateProjectSessions(projects, sessions)) updateAll()
|
||||
prevBusySnapshot = snapshotBusy(projects)
|
||||
})
|
||||
}
|
||||
|
||||
monitorInterval = setInterval(async () => {
|
||||
if (demoMode) {
|
||||
for (const p of projects) { p.activeSessions = 0; p.busySessions = 0 }
|
||||
generateMockActiveSessions(projects)
|
||||
generateMockBusySessions(projects)
|
||||
const transitioned = checkTransitions(projects, prevBusySnapshot)
|
||||
prevBusySnapshot = snapshotBusy(projects)
|
||||
if (transitioned.length > 0) playDoneSound()
|
||||
updateAll()
|
||||
} else {
|
||||
const sessions = await detectActiveSessions()
|
||||
const changed = updateProjectSessions(projects, sessions)
|
||||
const transitioned = checkTransitions(projects, prevBusySnapshot)
|
||||
prevBusySnapshot = snapshotBusy(projects)
|
||||
if (transitioned.length > 0) playDoneSound()
|
||||
if (changed) updateAll()
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
|
||||
@@ -10,6 +10,16 @@ export function timeAgo(ms: number): string {
|
||||
return `${Math.floor(diff / 2592000)}mo ago`
|
||||
}
|
||||
|
||||
export function elapsedCompact(ms: number): string {
|
||||
if (!ms) return ""
|
||||
const sec = Math.floor((Date.now() - ms) / 1000)
|
||||
if (sec < 5) return ""
|
||||
if (sec < 60) return `${sec}s`
|
||||
if (sec < 3600) return `${Math.floor(sec / 60)}m`
|
||||
if (sec < 86400) return `${Math.floor(sec / 3600)}h`
|
||||
return `${Math.floor(sec / 86400)}d`
|
||||
}
|
||||
|
||||
export function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes}B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
||||
|
||||
@@ -22,6 +22,9 @@ export interface Project {
|
||||
sessionCount: number
|
||||
totalMessages: number
|
||||
tags: string
|
||||
activeSessions: number
|
||||
busySessions: number
|
||||
lastActivityMs: number
|
||||
expanded: boolean
|
||||
sessions: SessionInfo[] | null
|
||||
branches: BranchInfo[] | null
|
||||
|
||||
Reference in New Issue
Block a user