fix(ui): make idle session badges transient

Keep idle status visible only briefly after active work finishes so the session list and headers emphasize meaningful state changes instead of permanent idle noise.
This commit is contained in:
Shantur Rathore 2026-05-03 22:00:05 +01:00
parent 3e9c152046
commit 0168703d4f
9 changed files with 156 additions and 34 deletions

View file

@ -1,4 +1,4 @@
import { Component, createMemo } from "solid-js"
import { Component, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import type { Instance } from "../types/instance"
import { getInstanceSessionIndicatorStatus } from "../stores/session-status"
import { FolderOpen, ShieldAlert, X } from "lucide-solid"
@ -20,9 +20,18 @@ function getPathBasename(path: string): string {
const InstanceTab: Component<InstanceTabProps> = (props) => {
const { t } = useI18n()
const aggregatedStatus = createMemo(() => getInstanceSessionIndicatorStatus(props.instance.id))
const [now, setNow] = createSignal(Date.now())
createEffect(() => {
if (typeof window === "undefined") return
const timer = window.setInterval(() => setNow(Date.now()), 1000)
onCleanup(() => window.clearInterval(timer))
})
const aggregatedStatus = createMemo(() => getInstanceSessionIndicatorStatus(props.instance.id, now()))
const statusClassName = createMemo(() => {
const status = aggregatedStatus()
if (!status) return null
return status === "permission" ? "session-permission" : `session-${status}`
})
const statusTitle = createMemo(() => {
@ -33,8 +42,10 @@ const InstanceTab: Component<InstanceTabProps> = (props) => {
return t("instanceTab.status.compacting")
case "working":
return t("instanceTab.status.working")
default:
case "idle":
return t("instanceTab.status.idle")
default:
return null
}
})
@ -51,17 +62,19 @@ const InstanceTab: Component<InstanceTabProps> = (props) => {
<span class="tab-label">
{getPathBasename(props.instance.folder)}
</span>
<span
class={`status-indicator session-status ml-auto ${statusClassName()}`}
title={statusTitle()}
aria-label={t("instanceTab.status.ariaLabel", { status: statusTitle() })}
>
{aggregatedStatus() === "permission" ? (
<ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" />
) : (
<span class="status-dot" />
)}
</span>
<Show when={statusClassName() && statusTitle()}>
<span
class={`status-indicator session-status ml-auto ${statusClassName()}`}
title={statusTitle() ?? undefined}
aria-label={t("instanceTab.status.ariaLabel", { status: statusTitle() ?? "" })}
>
{aggregatedStatus() === "permission" ? (
<ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" />
) : (
<span class="status-dot" />
)}
</span>
</Show>
<span
class="tab-close"
onClick={(e) => {

View file

@ -41,7 +41,7 @@ import SessionSidebar from "./shell/SessionSidebar"
import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests"
import RightPanel from "./shell/right-panel/RightPanel"
import { useDrawerChrome } from "./shell/useDrawerChrome"
import { getRetrySeconds, getSessionRetry, getSessionStatus } from "../../stores/session-status"
import { getRetrySeconds, getSessionRetry, getSessionStatus, shouldShowSessionStatus } from "../../stores/session-status"
import { Maximize2, ShieldAlert } from "lucide-solid"
import type { PromptInputApi } from "../prompt-input/types"
@ -329,6 +329,10 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const status = getSessionStatus(props.instance.id, activeSessionId)
const retry = getSessionRetry(props.instance.id, activeSessionId)
const showStatus = shouldShowSessionStatus(props.instance.id, activeSessionId, now())
if (!showStatus) {
return null
}
const text = retry
? (() => {
const seconds = getRetrySeconds(retry.next, now())

View file

@ -1,7 +1,7 @@
import { Component, For, Show, createSignal, createMemo, createEffect, JSX, onCleanup } from "solid-js"
import type { SessionStatus } from "../types/session"
import type { SessionThread } from "../stores/session-state"
import { getRetrySeconds, getSessionRetry, getSessionStatus } from "../stores/session-status"
import { getRetrySeconds, getSessionRetry, getSessionStatus, shouldShowSessionStatus } from "../stores/session-status"
import { Bot, User, Copy, Trash2, Pencil, ShieldAlert, ChevronDown, Search, Square, CheckSquare, MinusSquare, Split, RotateCw } from "lucide-solid"
import KeyboardHint from "./keyboard-hint"
import SessionRenameDialog from "./session-rename-dialog"
@ -427,6 +427,7 @@ const SessionList: Component<SessionListProps> = (props) => {
const needsQuestion = () => Boolean((session() as any)?.pendingQuestion)
const needsInput = () => needsPermission() || needsQuestion()
const statusClassName = () => (needsInput() ? "session-permission" : `session-${retry() ? "retrying" : status()}`)
const showStatus = () => needsInput() || shouldShowSessionStatus(props.instanceId, rowProps.sessionId, now())
const statusText = () =>
needsPermission()
? t("sessionList.status.needsPermission")
@ -520,14 +521,16 @@ const SessionList: Component<SessionListProps> = (props) => {
<ChevronDown class={`w-3.5 h-3.5 transition-transform ${rowProps.expanded ? "" : "-rotate-90"}`} />
</span>
</Show>
<span
class={`status-indicator session-status session-status-list ${statusClassName()} notranslate`}
title={statusTooltip()}
translate="no"
>
{needsInput() ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
{statusText()}
</span>
<Show when={showStatus()}>
<span
class={`status-indicator session-status session-status-list ${statusClassName()} notranslate`}
title={statusTooltip()}
translate="no"
>
{needsInput() ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
{statusText()}
</span>
</Show>
<Show when={showWorktreeBadge()}>
<span class="status-indicator session-status-list worktree-indicator" title={`Worktree: ${worktreeSlug()}`}>
<Split class="w-3.5 h-3.5" aria-hidden="true" />

View file

@ -1,4 +1,4 @@
import { mapSdkSessionRetry, mapSdkSessionStatus, type Session, type SessionStatus } from "../types/session"
import { getIdleSinceForStatusTransition, mapSdkSessionRetry, mapSdkSessionStatus, type Session, type SessionStatus } from "../types/session"
import type { Message } from "../types/message"
import type { FileDiff } from "@opencode-ai/sdk/v2/client"
@ -175,6 +175,7 @@ async function fetchSessions(instanceId: string): Promise<void> {
model: existingSession?.model ?? { providerId: "", modelId: "" },
status,
retry,
idleSince: getIdleSinceForStatusTransition(existingStatus, status, existingSession?.idleSince),
version: apiSession.version,
time: {
...apiSession.time,
@ -273,6 +274,7 @@ async function createSession(instanceId: string, agent?: string): Promise<Sessio
agent: selectedAgent,
model: defaultModel,
status: "idle",
idleSince: null,
version: response.data.version,
time: {
...response.data.time,
@ -378,12 +380,13 @@ async function forkSession(
title: info.title || "Forked Session",
parentId: info.parentID || null,
agent: info.agent || "",
model: {
providerId: info.model?.providerID || "",
modelId: info.model?.modelID || "",
},
status: "idle",
version: "0",
model: {
providerId: info.model?.providerID || "",
modelId: info.model?.modelID || "",
},
status: "idle",
idleSince: null,
version: "0",
time: info.time ? { ...info.time } : { created: Date.now(), updated: Date.now() },
revert: info.revert
? {

View file

@ -41,6 +41,7 @@ import {
import { showAlertDialog } from "./alerts"
import {
createClientSession,
getIdleSinceForStatusTransition,
mapSdkSessionRetry,
mapSdkSessionStatus,
type Session,
@ -161,6 +162,7 @@ function applySessionStatus(instanceId: string, sessionId: string, status: Sessi
session.status = status
session.retry = status === "working" ? nextRetry : null
session.idleSince = getIdleSinceForStatusTransition(current, status, session.idleSince)
// Auto-expand the parent thread when a child session starts working.
// Users can still collapse it; we only expand on the transition.
@ -227,6 +229,11 @@ async function fetchSessionInfo(instanceId: string, sessionId: string, directory
model: existing?.model ?? fetched.model,
status: existing?.status === "compacting" ? "compacting" : fetched.status,
retry: existing?.status === "compacting" ? null : fetched.retry,
idleSince: getIdleSinceForStatusTransition(
existing?.status,
existing?.status === "compacting" ? "compacting" : fetched.status,
existing?.idleSince,
),
pendingPermission: existing?.pendingPermission ?? fetched.pendingPermission,
pendingQuestion: existing?.pendingQuestion ?? false,
}
@ -458,6 +465,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo
},
status: "idle",
retry: null,
idleSince: null,
version: info.version || "0",
time: info.time
? { ...info.time }
@ -600,6 +608,7 @@ function handleSessionCompacted(instanceId: string, event: EventSessionCompacted
withSession(instanceId, sessionID, (session) => {
session.status = "working"
session.retry = null
session.idleSince = null
})
} else {
ensureSessionStatus(instanceId, sessionID, "working", (event as any)?.directory)

View file

@ -1,6 +1,6 @@
import { batch, createSignal } from "solid-js"
import type { Session, SessionStatus, Agent, Provider } from "../types/session"
import { getIdleSinceForStatusTransition, type Session, type SessionStatus, type Agent, type Provider } from "../types/session"
import { deleteSession, loadMessages } from "./session-api"
import { showToastNotification } from "../lib/notifications"
import { messageStoreBus } from "./message-v2/bus"
@ -353,6 +353,7 @@ function setSessionStatus(instanceId: string, sessionId: string, status: Session
if (session.status === status) return false
const previous = session.status
session.status = status
session.idleSince = getIdleSinceForStatusTransition(previous, status, session.idleSince)
if (status !== "working") {
session.retry = null
}

View file

@ -1,6 +1,8 @@
import assert from "node:assert/strict"
import { describe, it } from "node:test"
import { getIdleSinceForStatusTransition } from "../types/session.ts"
import { IDLE_STATUS_VISIBILITY_MS, shouldShowIdleStatus } from "./session-status.ts"
import { shouldSessionHoldWakeLock } from "./wake-lock-eligibility.ts"
describe("shouldSessionHoldWakeLock", () => {
@ -18,3 +20,28 @@ describe("shouldSessionHoldWakeLock", () => {
assert.equal(shouldSessionHoldWakeLock({ status: "working", pendingPermission: false, pendingQuestion: true }), false)
})
})
describe("idle status visibility", () => {
it("shows idle briefly after transitioning from active work", () => {
const idleSince = getIdleSinceForStatusTransition("working", "idle", null, 1_000)
assert.equal(idleSince, 1_000)
assert.equal(shouldShowIdleStatus({ status: "idle", idleSince }, 1_000), true)
assert.equal(shouldShowIdleStatus({ status: "idle", idleSince }, 1_000 + IDLE_STATUS_VISIBILITY_MS - 1), true)
assert.equal(shouldShowIdleStatus({ status: "idle", idleSince }, 1_000 + IDLE_STATUS_VISIBILITY_MS), false)
})
it("does not show idle for sessions that started idle", () => {
const idleSince = getIdleSinceForStatusTransition(undefined, "idle", null, 1_000)
assert.equal(idleSince, null)
assert.equal(shouldShowIdleStatus({ status: "idle", idleSince }, 1_000), false)
})
it("clears idle visibility when work resumes", () => {
const idleSince = getIdleSinceForStatusTransition("idle", "working", 1_000, 2_000)
assert.equal(idleSince, null)
assert.equal(shouldShowIdleStatus({ status: "working", idleSince }, 2_000), false)
})
})

View file

@ -2,6 +2,8 @@ import type { Session, SessionRetryState, SessionStatus } from "../types/session
import { getInstanceSessionIndicatorStatusCached, sessions } from "./session-state"
import { shouldSessionHoldWakeLock } from "./wake-lock-eligibility"
export const IDLE_STATUS_VISIBILITY_MS = 5000
function getSession(instanceId: string, sessionId: string): Session | null {
const instanceSessions = sessions().get(instanceId)
return instanceSessions?.get(sessionId) ?? null
@ -35,14 +37,55 @@ export function getSessionRetry(instanceId: string, sessionId: string): SessionR
return session?.retry ?? null
}
export function shouldShowIdleStatus(session: Pick<Session, "status" | "idleSince"> | null | undefined, now = Date.now()): boolean {
if (!session || session.status !== "idle") {
return false
}
if (typeof session.idleSince !== "number") {
return false
}
return now - session.idleSince < IDLE_STATUS_VISIBILITY_MS
}
export function shouldShowSessionStatus(instanceId: string, sessionId: string, now = Date.now()): boolean {
const session = getSession(instanceId, sessionId)
if (!session) {
return false
}
if (session.pendingPermission || session.pendingQuestion || session.retry) {
return true
}
return session.status !== "idle" || shouldShowIdleStatus(session, now)
}
export function getRetrySeconds(next: number, now = Date.now()): number {
return Math.max(0, Math.round((next - now) / 1000))
}
export type InstanceSessionIndicatorStatus = "permission" | SessionStatus
export function getInstanceSessionIndicatorStatus(instanceId: string): InstanceSessionIndicatorStatus {
return getInstanceSessionIndicatorStatusCached(instanceId)
export function getInstanceSessionIndicatorStatus(instanceId: string, now = Date.now()): InstanceSessionIndicatorStatus | null {
const aggregated = getInstanceSessionIndicatorStatusCached(instanceId)
if (aggregated !== "idle") {
return aggregated
}
const instanceSessions = sessions().get(instanceId)
if (!instanceSessions) {
return null
}
for (const session of instanceSessions.values()) {
if (shouldShowIdleStatus(session, now)) {
return "idle"
}
}
return null
}
export function isSessionBusy(instanceId: string, sessionId: string): boolean {

View file

@ -22,6 +22,23 @@ export interface SessionRetryState {
next: number
}
export function getIdleSinceForStatusTransition(
previousStatus: SessionStatus | null | undefined,
nextStatus: SessionStatus,
previousIdleSince: number | null | undefined,
now = Date.now(),
): number | null {
if (nextStatus !== "idle") {
return null
}
if (previousStatus && previousStatus !== "idle") {
return now
}
return previousIdleSince ?? null
}
export function mapSdkSessionStatus(status: SDKSessionStatus | null | undefined): SessionStatus {
if (!status || status.type === "idle") {
return "idle"
@ -58,6 +75,7 @@ export interface Session
pendingQuestion?: boolean // Indicates if session is waiting on user input
status: SessionStatus // Single source of truth for session status
retry?: SessionRetryState | null // Retry metadata for transient backoff states
idleSince?: number | null // Timestamp for short-lived idle badge visibility after active work completes
diff?: FileDiff[] // Session-level file diffs (hydrated via session.diff)
}
@ -76,6 +94,7 @@ export function createClientSession(
agent,
model,
status,
idleSince: null,
}
}