fix(ui): clear idle badges after viewed sessions

This commit is contained in:
Shantur Rathore 2026-05-07 16:07:53 +01:00
parent 58ccdae220
commit 37b64e3630
8 changed files with 86 additions and 64 deletions

View file

@ -1,9 +1,8 @@
import { Component, Show, createMemo, createSignal, onCleanup } from "solid-js"
import { Component, Show, createMemo } from "solid-js"
import type { Instance } from "../types/instance"
import { getInstanceSessionIndicatorStatus } from "../stores/session-status"
import { FolderOpen, ShieldAlert, X } from "lucide-solid"
import { useI18n } from "../lib/i18n"
import { useConfig } from "../stores/preferences"
interface InstanceTabProps {
instance: Instance
@ -21,17 +20,8 @@ function getPathBasename(path: string): string {
const InstanceTab: Component<InstanceTabProps> = (props) => {
const { t } = useI18n()
const { preferences } = useConfig()
const [now, setNow] = createSignal(Date.now())
if (typeof window !== "undefined") {
const timer = window.setInterval(() => setNow(Date.now()), 1000)
onCleanup(() => window.clearInterval(timer))
}
const aggregatedStatus = createMemo(() =>
getInstanceSessionIndicatorStatus(props.instance.id, now(), preferences().keepUnseenSubagentIdleStatus),
)
const aggregatedStatus = createMemo(() => getInstanceSessionIndicatorStatus(props.instance.id))
const statusClassName = createMemo(() => {
const status = aggregatedStatus()
if (!status) return null

View file

@ -44,7 +44,6 @@ import { useDrawerChrome } from "./shell/useDrawerChrome"
import { getRetrySeconds, getSessionRetry, getSessionStatus, shouldShowSessionStatus } from "../../stores/session-status"
import { Maximize2, ShieldAlert } from "lucide-solid"
import type { PromptInputApi } from "../prompt-input/types"
import { useConfig } from "../../stores/preferences"
import type { LayoutMode } from "./shell/types"
import {
@ -91,7 +90,6 @@ interface InstanceShellProps {
const InstanceShell2: Component<InstanceShellProps> = (props) => {
const { t, locale } = useI18n()
const { preferences } = useConfig()
const isRTL = () => locale() === "he"
const [sessionSidebarWidth, setSessionSidebarWidth] = createSignal(DEFAULT_SESSION_SIDEBAR_WIDTH)
@ -331,12 +329,7 @@ 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(),
preferences().keepUnseenSubagentIdleStatus,
)
const showStatus = shouldShowSessionStatus(props.instance.id, activeSessionId)
if (!showStatus) {
return null
}

View file

@ -24,7 +24,6 @@ import {
import { getGitRepoStatus, getWorktreeSlugForParentSession } from "../stores/worktrees"
import { getLogger } from "../lib/logger"
import { copyToClipboard } from "../lib/clipboard"
import { useConfig } from "../stores/preferences"
const log = getLogger("session")
@ -48,7 +47,6 @@ function formatSessionStatus(status: SessionStatus): string {
const SessionList: Component<SessionListProps> = (props) => {
const { t } = useI18n()
const { preferences } = useConfig()
const [renameTarget, setRenameTarget] = createSignal<{ id: string; title: string; label: string } | null>(null)
const [isRenaming, setIsRenaming] = createSignal(false)
@ -429,14 +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(),
preferences().keepUnseenSubagentIdleStatus,
)
const showStatus = () => needsInput() || shouldShowSessionStatus(props.instanceId, rowProps.sessionId)
const statusText = () =>
needsPermission()
? t("sessionList.status.needsPermission")

View file

@ -8,7 +8,7 @@ import PromptInput from "../prompt-input"
import PromptAttachmentsBar from "../prompt-input/PromptAttachmentsBar"
import { getAttachments, removeAttachment } from "../../stores/attachments"
import { instances } from "../../stores/instances"
import { loadMessages, sendMessage, forkSession, renameSession, isSessionMessagesLoading, markSessionIdleSeen, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions"
import { loadMessages, sendMessage, forkSession, renameSession, isSessionMessagesLoading, markViewedSessionIdleSeen, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions"
import { IDLE_STATUS_VISIBILITY_MS, isSessionBusy as getSessionBusyStatus } from "../../stores/session-status"
import { deleteMessage } from "../../stores/session-actions"
import { showAlertDialog } from "../../stores/alerts"
@ -17,6 +17,7 @@ import { requestData } from "../../lib/opencode-api"
import { useI18n } from "../../lib/i18n"
import type { PromptInputApi, PromptInsertMode } from "../prompt-input/types"
import { clearConversationPlaybackForSession } from "../../stores/conversation-speech"
import { useConfig } from "../../stores/preferences"
const log = getLogger("session")
@ -41,6 +42,7 @@ interface SessionViewProps {
export const SessionView: Component<SessionViewProps> = (props) => {
const { t } = useI18n()
const { preferences } = useConfig()
const session = () => props.activeSessions.get(props.sessionId)
const messagesLoading = createMemo(() => isSessionMessagesLoading(props.instanceId, props.sessionId))
const messageStore = createMemo(() => messageStoreBus.getOrCreate(props.instanceId))
@ -80,6 +82,26 @@ export const SessionView: Component<SessionViewProps> = (props) => {
requestAnimationFrame(() => scrollToBottomHandle?.())
})
}
function getSeenIdleSignature(currentSession: Session, keepUnseenSubagentIdleStatus: boolean): string {
const ids: string[] = []
if (currentSession.status === "idle" && typeof currentSession.idleSince === "number") {
ids.push(`${currentSession.id}:${currentSession.idleSince}`)
}
if (currentSession.parentId === null && !keepUnseenSubagentIdleStatus) {
for (const child of props.activeSessions.values()) {
if (child.parentId !== currentSession.id) continue
if (child.status !== "idle") continue
if (typeof child.idleSince !== "number") continue
ids.push(`${child.id}:${child.idleSince}`)
}
}
return ids.sort().join("|")
}
createEffect(
on(
() => props.isActive,
@ -95,16 +117,17 @@ export const SessionView: Component<SessionViewProps> = (props) => {
createEffect(() => {
const currentSession = session()
if (!props.isActive || !currentSession) return
if (currentSession.status !== "idle") return
if (typeof currentSession.idleSince !== "number") return
const idleSince = currentSession.idleSince
const keepUnseenSubagentIdleStatus = preferences().keepUnseenSubagentIdleStatus
const seenIdleSignature = getSeenIdleSignature(currentSession, keepUnseenSubagentIdleStatus)
if (!seenIdleSignature) return
const timeout = window.setTimeout(() => {
const latestSession = session()
if (!props.isActive || !latestSession) return
if (latestSession.status !== "idle") return
if (latestSession.idleSince !== idleSince) return
markSessionIdleSeen(props.instanceId, latestSession.id)
const latestKeepUnseenSubagentIdleStatus = preferences().keepUnseenSubagentIdleStatus
if (getSeenIdleSignature(latestSession, latestKeepUnseenSubagentIdleStatus) !== seenIdleSignature) return
markViewedSessionIdleSeen(props.instanceId, latestSession.id, latestKeepUnseenSubagentIdleStatus)
}, IDLE_STATUS_VISIBILITY_MS)
onCleanup(() => window.clearTimeout(timeout))

View file

@ -322,6 +322,44 @@ function markSessionIdleSeen(instanceId: string, sessionId: string): void {
})
}
function markViewedSessionIdleSeen(
instanceId: string,
sessionId: string,
keepUnseenSubagentIdleStatus: boolean,
): void {
setSessions((prev) => {
const instanceSessions = prev.get(instanceId)
if (!instanceSessions) return prev
const viewedSession = instanceSessions.get(sessionId)
if (!viewedSession) return prev
const idsToClear = new Set<string>([sessionId])
if (viewedSession.parentId === null && !keepUnseenSubagentIdleStatus) {
for (const session of instanceSessions.values()) {
if (session.parentId === sessionId) idsToClear.add(session.id)
}
}
let changed = false
const updatedSessions = new Map(instanceSessions)
for (const id of idsToClear) {
const session = updatedSessions.get(id)
if (!session) continue
if (session.status !== "idle") continue
if (typeof session.idleSince !== "number") continue
updatedSessions.set(id, { ...session, idleSince: null })
changed = true
}
if (!changed) return prev
const next = new Map(prev)
next.set(instanceId, updatedSessions)
return next
})
}
function setActiveSession(instanceId: string, sessionId: string): void {
setActiveSessionId((prev) => {
const next = new Map(prev)
@ -745,6 +783,7 @@ export {
setSessionPendingPermission,
setSessionPendingQuestion,
markSessionIdleSeen,
markViewedSessionIdleSeen,
setSessionStatus,
setActiveSession,

View file

@ -30,40 +30,34 @@ describe("idle status visibility", () => {
const idleSince = getIdleSinceForStatusTransition("working", "idle", null, 1_000)
assert.equal(idleSince, 1_000)
assert.equal(shouldShowIdleStatus({ status: "idle", idleSince, parentId: null }, 1_000), true)
assert.equal(shouldShowIdleStatus({ status: "idle", idleSince, parentId: null }, 1_000 + IDLE_STATUS_VISIBILITY_MS), true)
assert.equal(shouldShowIdleStatus({ status: "idle", idleSince, parentId: null }), true)
assert.equal(shouldShowIdleStatus({ status: "idle", idleSince, parentId: null }), true)
})
it("auto-hides subagent idle after the transient delay by default", () => {
it("keeps subagent idle visible until the parent or child session is seen", () => {
const idleSince = getIdleSinceForStatusTransition("working", "idle", null, 1_000)
assert.equal(shouldShowIdleStatus({ status: "idle", idleSince, parentId: "parent" }, 1_000), true)
assert.equal(
shouldShowIdleStatus({ status: "idle", idleSince, parentId: "parent" }, 1_000 + IDLE_STATUS_VISIBILITY_MS),
false,
)
assert.equal(shouldShowIdleStatus({ status: "idle", idleSince, parentId: "parent" }), true)
assert.equal(shouldShowIdleStatus({ status: "idle", idleSince, parentId: "parent" }), true)
})
it("can keep subagent idle visible until viewed", () => {
it("does not use the keep-unseen setting to age out visible idle markers", () => {
const idleSince = getIdleSinceForStatusTransition("working", "idle", null, 1_000)
assert.equal(
shouldShowIdleStatus({ status: "idle", idleSince, parentId: "parent" }, 1_000 + IDLE_STATUS_VISIBILITY_MS, true),
true,
)
assert.equal(shouldShowIdleStatus({ status: "idle", idleSince, parentId: "parent" }), true)
})
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, parentId: null }, 1_000), false)
assert.equal(shouldShowIdleStatus({ status: "idle", idleSince, parentId: null }), 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, parentId: null }, 2_000), false)
assert.equal(shouldShowIdleStatus({ status: "working", idleSince, parentId: null }), false)
})
})

View file

@ -39,8 +39,6 @@ export function getSessionRetry(instanceId: string, sessionId: string): SessionR
export function shouldShowIdleStatus(
session: Pick<Session, "status" | "idleSince" | "parentId"> | null | undefined,
now = Date.now(),
keepUnseenSubagentIdleStatus = false,
): boolean {
if (!session || session.status !== "idle") {
return false
@ -50,18 +48,12 @@ export function shouldShowIdleStatus(
return false
}
if (session.parentId && !keepUnseenSubagentIdleStatus) {
return now - session.idleSince < IDLE_STATUS_VISIBILITY_MS
}
return true
}
export function shouldShowSessionStatus(
instanceId: string,
sessionId: string,
now = Date.now(),
keepUnseenSubagentIdleStatus = false,
): boolean {
const session = getSession(instanceId, sessionId)
if (!session) {
@ -72,7 +64,7 @@ export function shouldShowSessionStatus(
return true
}
return session.status !== "idle" || shouldShowIdleStatus(session, now, keepUnseenSubagentIdleStatus)
return session.status !== "idle" || shouldShowIdleStatus(session)
}
export function getRetrySeconds(next: number, now = Date.now()): number {
@ -83,8 +75,6 @@ export type InstanceSessionIndicatorStatus = "permission" | SessionStatus
export function getInstanceSessionIndicatorStatus(
instanceId: string,
now = Date.now(),
keepUnseenSubagentIdleStatus = false,
): InstanceSessionIndicatorStatus | null {
const aggregated = getInstanceSessionIndicatorStatusCached(instanceId)
if (aggregated !== "idle") {
@ -97,7 +87,7 @@ export function getInstanceSessionIndicatorStatus(
}
for (const session of instanceSessions.values()) {
if (shouldShowIdleStatus(session, now, keepUnseenSubagentIdleStatus)) {
if (shouldShowIdleStatus(session)) {
return "idle"
}
}

View file

@ -25,6 +25,7 @@ import {
isSessionParentExpanded,
loading,
markSessionIdleSeen,
markViewedSessionIdleSeen,
providers,
sessionInfoByInstance,
sessions,
@ -126,6 +127,7 @@ export {
loadMessages,
loading,
markSessionIdleSeen,
markViewedSessionIdleSeen,
providers,
sendMessage,
sessionInfoByInstance,