diff --git a/packages/ui/src/components/instance-tab.tsx b/packages/ui/src/components/instance-tab.tsx index 24852a4a..d9681e67 100644 --- a/packages/ui/src/components/instance-tab.tsx +++ b/packages/ui/src/components/instance-tab.tsx @@ -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 = (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 diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index badb76ac..727504c9 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -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 = (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 = (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 } diff --git a/packages/ui/src/components/session-list.tsx b/packages/ui/src/components/session-list.tsx index 6d229520..24a6e7ab 100644 --- a/packages/ui/src/components/session-list.tsx +++ b/packages/ui/src/components/session-list.tsx @@ -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 = (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 = (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") diff --git a/packages/ui/src/components/session/session-view.tsx b/packages/ui/src/components/session/session-view.tsx index 3efbd542..0ce385cd 100644 --- a/packages/ui/src/components/session/session-view.tsx +++ b/packages/ui/src/components/session/session-view.tsx @@ -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 = (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 = (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 = (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)) diff --git a/packages/ui/src/stores/session-state.ts b/packages/ui/src/stores/session-state.ts index d2292313..f5e82cbd 100644 --- a/packages/ui/src/stores/session-state.ts +++ b/packages/ui/src/stores/session-state.ts @@ -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([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, diff --git a/packages/ui/src/stores/session-status.test.ts b/packages/ui/src/stores/session-status.test.ts index 8fdcd1f2..29d7329b 100644 --- a/packages/ui/src/stores/session-status.test.ts +++ b/packages/ui/src/stores/session-status.test.ts @@ -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) }) }) diff --git a/packages/ui/src/stores/session-status.ts b/packages/ui/src/stores/session-status.ts index ab557cdc..503b7fde 100644 --- a/packages/ui/src/stores/session-status.ts +++ b/packages/ui/src/stores/session-status.ts @@ -39,8 +39,6 @@ export function getSessionRetry(instanceId: string, sessionId: string): SessionR export function shouldShowIdleStatus( session: Pick | 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" } } diff --git a/packages/ui/src/stores/sessions.ts b/packages/ui/src/stores/sessions.ts index fe138047..96ac43e6 100644 --- a/packages/ui/src/stores/sessions.ts +++ b/packages/ui/src/stores/sessions.ts @@ -25,6 +25,7 @@ import { isSessionParentExpanded, loading, markSessionIdleSeen, + markViewedSessionIdleSeen, providers, sessionInfoByInstance, sessions, @@ -126,6 +127,7 @@ export { loadMessages, loading, markSessionIdleSeen, + markViewedSessionIdleSeen, providers, sendMessage, sessionInfoByInstance,