diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx
index 7c69237e..3e62b605 100644
--- a/app/(tabs)/index.tsx
+++ b/app/(tabs)/index.tsx
@@ -56,6 +56,8 @@ function SessionItem({
onDelete: () => void
}) {
const { t } = useTranslation()
+ const status = useEvents((s) => s.sessionStatus[session.id])
+ const working = status?.type === "busy" || status?.type === "retry"
const onPress = () => {
router.push({
@@ -82,6 +84,15 @@ function SessionItem({
onLongPress={onLongPress}
testID={`session-item-${session.id}`}
>
+ {working && (
+
+
+
+ )}
@@ -187,6 +198,7 @@ export default function SessionsScreen() {
} = useConnections()
const authError = useEvents((s) => s.authError)
const reconnect = useEvents((s) => s.connect)
+ const refreshSessionStatuses = useEvents((s) => s.refreshSessionStatuses)
const loadCatalog = useCatalog((s) => s.load)
const dirSheetRef = useRef(null)
const browserSheetRef = useRef(null)
@@ -199,6 +211,11 @@ export default function SessionsScreen() {
// all groups start expanded (#67).
const [collapsedDirs, setCollapsedDirs] = useState>(new Set())
+ const refreshSessions = useCallback(async () => {
+ await loadSessions()
+ await refreshSessionStatuses()
+ }, [loadSessions, refreshSessionStatuses])
+
const toggleGroup = useCallback((directory: string) => {
setCollapsedDirs((prev) => {
const next = new Set(prev)
@@ -244,32 +261,32 @@ export default function SessionsScreen() {
const handleSwitchDirectory = useCallback(
async (dir?: string) => {
await switchDirectory(dir)
- loadSessions()
+ refreshSessions()
refreshProject()
loadCatalog()
},
- [switchDirectory, loadSessions, refreshProject, loadCatalog],
+ [switchDirectory, refreshSessions, refreshProject, loadCatalog],
)
useFocusEffect(
useCallback(() => {
if (client) {
- loadSessions()
+ refreshSessions()
refreshProject()
}
- }, [client, loadSessions, refreshProject]),
+ }, [client, refreshSessions, refreshProject]),
)
const onRefresh = useCallback(async () => {
setRefreshing(true)
try {
- await Promise.all([loadSessions(), refreshProject()])
+ await Promise.all([refreshSessions(), refreshProject()])
} catch (err) {
console.error("Refresh failed:", err)
} finally {
setRefreshing(false)
}
- }, [loadSessions, refreshProject])
+ }, [refreshSessions, refreshProject])
const handleRename = useCallback((session: Session) => {
setRenameText(session.title || "")
@@ -286,14 +303,14 @@ export default function SessionsScreen() {
await renameClient.session.update(renaming.id, { title })
setRenaming(null)
setRenameText("")
- loadSessions()
+ refreshSessions()
} catch (err) {
console.error("Rename failed:", err)
Alert.alert(t("sessionsList.alerts.renameFailedTitle"), t("sessionsList.alerts.renameFailedMessage"))
} finally {
renamingInFlight.current = false
}
- }, [renaming, renameText, client, clientForDirectory, loadSessions, t])
+ }, [renaming, renameText, client, clientForDirectory, refreshSessions, t])
const handleDelete = useCallback(
(session: Session) => {
@@ -971,6 +988,12 @@ const styles = StyleSheet.create({
sessionContent: {
flex: 1,
},
+ sessionActivity: {
+ width: 24,
+ marginRight: 8,
+ alignItems: "center",
+ justifyContent: "center",
+ },
sessionHeader: {
flexDirection: "row",
alignItems: "center",
diff --git a/app/session/[id].tsx b/app/session/[id].tsx
index 97f885f0..61399540 100644
--- a/app/session/[id].tsx
+++ b/app/session/[id].tsx
@@ -39,6 +39,7 @@ import { useConnections } from "../../src/stores/connections"
import { useAuth } from "../../src/stores/auth"
import { useCatalog } from "../../src/stores/catalog"
import { useSpeech } from "../../src/lib/speech"
+import { isSessionWorking } from "../../src/lib/session-status-hydration"
// --- Builtin slash commands ---
const BUILTIN_COMMANDS: SlashCommand[] = [
@@ -101,8 +102,11 @@ export default function SessionScreen() {
unrevertSession,
} = useSessions()
- // Derive sending state for this specific session
- const isSending = useSessions((s) => !!(currentSession && s.sending[currentSession.id]))
+ // Tasks may start from another client, so server status must drive the
+ // composer as well as the optimistic flag set by this device.
+ const optimisticSending = useSessions((s) => !!(currentSession && s.sending[currentSession.id]))
+ const serverStatus = useEvents((s) => (currentSession ? s.sessionStatus[currentSession.id] : undefined))
+ const isSending = isSessionWorking(serverStatus, optimisticSending)
const { authenticateForMessage } = useAuth()
const { client, clientForDirectory } = useConnections()
diff --git a/src/components/chat/StatusIndicator.tsx b/src/components/chat/StatusIndicator.tsx
index 1027c22c..16fcf27c 100644
--- a/src/components/chat/StatusIndicator.tsx
+++ b/src/components/chat/StatusIndicator.tsx
@@ -2,6 +2,7 @@ import { View, Text, StyleSheet, ActivityIndicator } from "react-native"
import { useTranslation } from "react-i18next"
import { useEvents } from "../../stores/events"
import { useSessions } from "../../stores/sessions"
+import { isSessionWorking } from "../../lib/session-status-hydration"
interface Props {
sessionID: string
@@ -14,11 +15,7 @@ export function StatusIndicator({ sessionID, isDark }: Props) {
const text = useEvents((s) => s.statusText[sessionID])
const optimistic = useSessions((s) => s.sending[sessionID])
- // SSE status is the source of truth. The optimistic `sending` flag only
- // covers the gap between the user tapping send and SSE confirming busy.
- // Once SSE reports idle, the indicator hides regardless of the optimistic flag.
- const sseBusy = status && status.type !== "idle"
- const busy = sseBusy || (optimistic && !status)
+ const busy = isSessionWorking(status, !!optimistic)
if (!busy) return null
const label =
diff --git a/src/lib/sdk.ts b/src/lib/sdk.ts
index 89ffb68c..152e8401 100644
--- a/src/lib/sdk.ts
+++ b/src/lib/sdk.ts
@@ -49,6 +49,11 @@ export interface Session {
}
}
+export type SessionStatus =
+ | { type: "idle" }
+ | { type: "busy" }
+ | { type: "retry"; attempt: number; message: string }
+
export interface Message {
id: string
sessionID: string
@@ -373,6 +378,8 @@ export function createClient(config: ClientConfig) {
get: (sessionID: string) => request(config, `/session/${sessionID}`),
+ status: () => request>(config, "/session/status"),
+
create: (params?: { title?: string }) =>
request(config, "/session", {
method: "POST",
diff --git a/src/lib/session-list.integration.test.ts b/src/lib/session-list.integration.test.ts
index 28583708..cbb46622 100644
--- a/src/lib/session-list.integration.test.ts
+++ b/src/lib/session-list.integration.test.ts
@@ -22,7 +22,7 @@ before(async () => {
// --seed-sessions pre-populates two sessions in two DIFFERENT directories:
// seed-default -> /mock/project (updated now-120s)
// seed-other -> /mock/project/other-dir (updated now-60s, more recent)
- mock = createMockOpencodeServer({ port: PORT, seedSessions: true })
+ mock = createMockOpencodeServer({ port: PORT, seedSessions: true, busySessionIDs: ["seed-default", "seed-other"] })
await mock.listen()
base = mock.url
})
@@ -80,6 +80,16 @@ test("hit the experimental endpoint, not the directory-scoped one", async () =>
assert.equal(global.length, 2, "/experimental/session returns all sessions globally")
})
+test("session status snapshots are scoped to each session directory", async () => {
+ const defaultStatus = await (await fetch(`${base}/session/status`)).json()
+ const otherStatus = await (
+ await fetch(`${base}/session/status`, { headers: { "x-opencode-directory": "/mock/project/other-dir" } })
+ ).json()
+
+ assert.deepEqual(defaultStatus, { "seed-default": { type: "busy" } })
+ assert.deepEqual(otherStatus, { "seed-other": { type: "busy" } })
+})
+
test("older servers (404 on /experimental/session) fall back to /session and still list globally", async () => {
// Simulate a server that predates /experimental/session: getExperimental
// resolves null (as it would on a 404), forcing the legacy path.
diff --git a/src/lib/session-status-hydration.test.ts b/src/lib/session-status-hydration.test.ts
new file mode 100644
index 00000000..347ed12c
--- /dev/null
+++ b/src/lib/session-status-hydration.test.ts
@@ -0,0 +1,32 @@
+import assert from "node:assert/strict"
+import test from "node:test"
+import { isSessionWorking, mergeSessionStatusSnapshot } from "./session-status-hydration.ts"
+import type { SessionStatus } from "./sdk.ts"
+
+test("server status drives working state for tasks started elsewhere", () => {
+ assert.equal(isSessionWorking({ type: "busy" }, false), true)
+ assert.equal(isSessionWorking({ type: "retry", attempt: 1, message: "retrying" }, false), true)
+ assert.equal(isSessionWorking({ type: "idle" }, false), false)
+ assert.equal(isSessionWorking({ type: "idle" }, true), true)
+ assert.equal(isSessionWorking(undefined, true), true)
+ assert.equal(isSessionWorking(undefined, false), false)
+})
+
+test("hydrates current status without overwriting a newer SSE event", () => {
+ const oldBusy: SessionStatus = { type: "busy" }
+ const baseline = { stale: oldBusy, changed: oldBusy }
+ const current = { ...baseline, changed: { type: "idle" } as SessionStatus }
+
+ assert.deepEqual(
+ mergeSessionStatusSnapshot(current, baseline, { stale: { type: "busy" }, changed: { type: "busy" } }, [
+ "stale",
+ "changed",
+ "missing",
+ ]),
+ {
+ stale: { type: "busy" },
+ changed: { type: "idle" },
+ missing: { type: "idle" },
+ },
+ )
+})
diff --git a/src/lib/session-status-hydration.ts b/src/lib/session-status-hydration.ts
new file mode 100644
index 00000000..677446a2
--- /dev/null
+++ b/src/lib/session-status-hydration.ts
@@ -0,0 +1,19 @@
+import type { SessionStatus } from "./sdk"
+
+export function isSessionWorking(status: SessionStatus | undefined, optimistic: boolean): boolean {
+ return optimistic || (!!status && status.type !== "idle")
+}
+
+export function mergeSessionStatusSnapshot(
+ current: Record,
+ baseline: Record,
+ snapshot: Record,
+ sessionIDs: string[],
+): Record {
+ const next = { ...current }
+ for (const id of sessionIDs) {
+ if (current[id] !== baseline[id]) continue
+ next[id] = snapshot[id] ?? { type: "idle" }
+ }
+ return next
+}
diff --git a/src/stores/events.ts b/src/stores/events.ts
index c53a8481..104f030e 100644
--- a/src/stores/events.ts
+++ b/src/stores/events.ts
@@ -9,10 +9,8 @@ import { AnalyticsEvent, track } from "../lib/analytics"
import { recordSuccessfulSession } from "../lib/store-review"
import { isAuthError } from "../lib/api-error"
import { isSessionActuallyIdle } from "../lib/session-status-reconcile"
-import type { Client, Part, Session, Message } from "../lib/sdk"
-
-// Session status from the server
-type SessionStatus = { type: "idle" } | { type: "busy" } | { type: "retry"; attempt: number; message: string }
+import { mergeSessionStatusSnapshot } from "../lib/session-status-hydration"
+import type { Client, Part, Session, Message, SessionStatus } from "../lib/sdk"
interface EventsState {
connected: boolean
@@ -57,6 +55,7 @@ interface EventsState {
connect: () => void
disconnect: () => void
+ refreshSessionStatuses: () => Promise
}
let controller: AbortController | null = null
@@ -155,6 +154,46 @@ export const useEvents = create((set, get) => ({
permissions: {},
questions: {},
+ refreshSessionStatuses: async () => {
+ const sessions = useSessions.getState().sessions
+ if (sessions.length === 0) return
+
+ const connection = useConnections.getState()
+ const rootClient = connection.client
+ if (!rootClient) return
+ const groups = new Map()
+ for (const session of sessions) {
+ groups.set(session.directory, [...(groups.get(session.directory) ?? []), session.id])
+ }
+
+ const baseline = get().sessionStatus
+ const entries = [...groups.entries()]
+ const results = await Promise.allSettled(
+ entries.map(([directory]) => {
+ const client = connection.clientForDirectory(directory) ?? rootClient
+ return client.session.status()
+ }),
+ )
+
+ if (useConnections.getState().client !== rootClient) return
+
+ set((state) => {
+ let sessionStatus = state.sessionStatus
+ let statusText = state.statusText
+ for (const [index, result] of results.entries()) {
+ if (result.status === "rejected") {
+ console.warn("[Events] Failed to refresh session status for", entries[index][0], result.reason)
+ continue
+ }
+ sessionStatus = mergeSessionStatusSnapshot(sessionStatus, baseline, result.value, entries[index][1])
+ for (const id of entries[index][1]) {
+ if (sessionStatus[id]?.type === "idle" && statusText[id]) statusText = { ...statusText, [id]: "" }
+ }
+ }
+ return { sessionStatus, statusText }
+ })
+ },
+
connect: () => {
controller?.abort()
controller = null
diff --git a/tests/fixtures/mock-opencode-server.ts b/tests/fixtures/mock-opencode-server.ts
index 29ab7f22..dfdb59a3 100644
--- a/tests/fixtures/mock-opencode-server.ts
+++ b/tests/fixtures/mock-opencode-server.ts
@@ -89,6 +89,8 @@ export interface MockServerOptions {
* open regression for GitHub issues #46/#48).
*/
seedSessions?: boolean
+ /** Session IDs returned as busy by the directory-scoped GET /session/status endpoint. */
+ busySessionIDs?: string[]
/**
* Pre-populate a session (id "seed-diff", in DEFAULT_DIRECTORY so it shows
* up on the normal home-tab session list) with ONE assistant message that
@@ -212,12 +214,14 @@ export function createMockOpencodeServer(opts: MockServerOptions) {
replyText = DEFAULT_REPLY_TEXT,
replyDelayMs = 300,
seedSessions = false,
+ busySessionIDs = [],
seedDiff = false,
} = opts
const sessions = new Map()
const messagesBySession = new Map()
const sseClients = new Set()
+ const busySessions = new Set(busySessionIDs)
if (seedDiff) {
const now = Date.now()
@@ -554,6 +558,18 @@ export function createMockOpencodeServer(opts: MockServerOptions) {
if (method === "GET" && path === "/experimental/session") {
return json(res, 200, Array.from(sessions.values()))
}
+ if (method === "GET" && path === "/session/status") {
+ const directory = requestDirectory(req)
+ return json(
+ res,
+ 200,
+ Object.fromEntries(
+ Array.from(sessions.values())
+ .filter((session) => session.directory === directory && busySessions.has(session.id))
+ .map((session) => [session.id, { type: "busy" }]),
+ ),
+ )
+ }
if (method === "GET" && path === "/session") {
// Directory-less "all sessions across all projects" list: the app's
// loadSessions() (src/stores/sessions.ts) uses clientForDirectory(undefined)