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)