mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-07-10 00:14:27 +00:00
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:
parent
3e9c152046
commit
0168703d4f
9 changed files with 156 additions and 34 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
? {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue