Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 31 additions & 8 deletions app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -82,6 +84,15 @@ function SessionItem({
onLongPress={onLongPress}
testID={`session-item-${session.id}`}
>
{working && (
<View style={styles.sessionActivity} testID={`session-activity-${session.id}`}>
<ActivityIndicator
size="small"
color={isDark ? "#a78bfa" : "#6d28d9"}
accessibilityLabel={t("chat.statusIndicator.working")}
/>
</View>
)}
<View style={styles.sessionContent}>
<View style={styles.sessionHeader}>
<Text style={[styles.sessionTitle, isDark && styles.textDark]} numberOfLines={1}>
Expand Down Expand Up @@ -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<BottomSheet>(null)
const browserSheetRef = useRef<BottomSheet>(null)
Expand All @@ -199,6 +211,11 @@ export default function SessionsScreen() {
// all groups start expanded (#67).
const [collapsedDirs, setCollapsedDirs] = useState<Set<string>>(new Set())

const refreshSessions = useCallback(async () => {
await loadSessions()
await refreshSessionStatuses()
}, [loadSessions, refreshSessionStatuses])

const toggleGroup = useCallback((directory: string) => {
setCollapsedDirs((prev) => {
const next = new Set(prev)
Expand Down Expand Up @@ -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 || "")
Expand All @@ -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) => {
Expand Down Expand Up @@ -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",
Expand Down
8 changes: 6 additions & 2 deletions app/session/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand Down Expand Up @@ -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()
Expand Down
7 changes: 2 additions & 5 deletions src/components/chat/StatusIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 =
Expand Down
7 changes: 7 additions & 0 deletions src/lib/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -373,6 +378,8 @@ export function createClient(config: ClientConfig) {

get: (sessionID: string) => request<Session>(config, `/session/${sessionID}`),

status: () => request<Record<string, SessionStatus>>(config, "/session/status"),

create: (params?: { title?: string }) =>
request<Session>(config, "/session", {
method: "POST",
Expand Down
12 changes: 11 additions & 1 deletion src/lib/session-list.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
Expand Down Expand Up @@ -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.
Expand Down
32 changes: 32 additions & 0 deletions src/lib/session-status-hydration.test.ts
Original file line number Diff line number Diff line change
@@ -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" },
},
)
})
19 changes: 19 additions & 0 deletions src/lib/session-status-hydration.ts
Original file line number Diff line number Diff line change
@@ -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<string, SessionStatus>,
baseline: Record<string, SessionStatus>,
snapshot: Record<string, SessionStatus>,
sessionIDs: string[],
): Record<string, SessionStatus> {
const next = { ...current }
for (const id of sessionIDs) {
if (current[id] !== baseline[id]) continue
next[id] = snapshot[id] ?? { type: "idle" }
}
return next
}
47 changes: 43 additions & 4 deletions src/stores/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -57,6 +55,7 @@ interface EventsState {

connect: () => void
disconnect: () => void
refreshSessionStatuses: () => Promise<void>
}

let controller: AbortController | null = null
Expand Down Expand Up @@ -155,6 +154,46 @@ export const useEvents = create<EventsState>((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<string, string[]>()
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
Expand Down
16 changes: 16 additions & 0 deletions tests/fixtures/mock-opencode-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, StoredSession>()
const messagesBySession = new Map<string, StoredMessage[]>()
const sseClients = new Set<http.ServerResponse>()
const busySessions = new Set(busySessionIDs)

if (seedDiff) {
const now = Date.now()
Expand Down Expand Up @@ -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)
Expand Down