Skip to content

Commit d461f70

Browse files
authored
fix(animation): spinner now driven by server status, not job_count (#423)
Problem: 1. Spinner flashed on every UI open. The gate was jobs.is_running(), which every HTTP request bumps (including pure queries), so toggle on issued 3-4 queries and the spinner blinked. 2. Spinner never appeared after attach or toggle off-then-on. SSE cannot replay past events, and toggle off wiped the only local copy of status_data. 3. Spinner could stay on forever after the model finished: a. sync's GET response, stale by the time it landed, overwrote the cache with a busy snapshot and started the spinner against an already-idle server (which emits no follow-up). b. SSE idle before set_active was skipped (active_session guard), so replay read the stale busy. Solution: - Gate the spinner on a new `_should_animate()`: status_data is non-idle AND tagged with the active session's sessionID. - Per-session `last_status_map` (always a table) holds the cache. SSE writes unconditionally; sync merges only missing entries (SSE wins). - New `OpencodeApiClient:list_session_status` + `M.sync_from_server()` hydrate on setup and replay to status_data. - `M.refresh()` is the single entry point that reconciles the running state with status_data and active_session. Tests: - `does not regress to busy when sync returns a stale snapshot after SSE idle` covers race 3a. - `replays the active session from the cache (handles sync-before_set_active)` covers race 3b. - `merges the response into the cache (only fills missing entries)` asserts SSE-preserved entries survive sync. - `does not leave a stale spinner running when the model finishes during a hide` covers the toggle off / model finishes / toggle on scenario.
1 parent 69aa869 commit d461f70

4 files changed

Lines changed: 472 additions & 60 deletions

File tree

lua/opencode/api_client.lua

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,13 @@ function OpencodeApiClient:list_sessions(directory)
195195
return self:_call('/session', 'GET', nil, { directory = directory })
196196
end
197197

198+
--- List the current status of all sessions in a workspace.
199+
--- @param directory string|nil Directory path
200+
--- @return Promise<{[string]: OpencodeSessionStatusInfo}>
201+
function OpencodeApiClient:list_session_status(directory)
202+
return self:_call('/session/status', 'GET', nil, { directory = directory })
203+
end
204+
198205
--- List sessions across all projects (experimental global endpoint).
199206
--- Bypasses _call's automatic directory injection so the server returns all
200207
--- directories instead of being filtered to the current cwd.

lua/opencode/types.lua

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -775,3 +775,10 @@
775775
---@class OpencodeSelectionRange
776776
---@field start number Starting line number (inclusive)
777777
---@field stop number Ending line number (inclusive)
778+
779+
---@class OpencodeSessionStatusInfo
780+
---@field type 'idle'|'busy'|'retry' Current status of the session
781+
---@field message? string Human-readable detail (populated for `retry`)
782+
---@field attempt? number Retry attempt counter (populated for `retry`)
783+
---@field next? number Server-side timestamp of the next retry (populated for `retry`)
784+
---@field action? table Optional retry action metadata (populated for `retry`)

lua/opencode/ui/loading_animation.lua

Lines changed: 90 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ M._animation = {
88
frames = nil,
99
text = 'Thinking... ',
1010
status_data = nil,
11+
status_session_id = nil,
1112
current_frame = 1,
1213
timer = nil,
1314
fps = 10,
1415
extmark_id = nil,
1516
ns_id = vim.api.nvim_create_namespace('opencode_loading_animation'),
1617
status_event_manager = nil,
18+
last_status_map = {},
1719
}
1820

1921
---@param status table|nil
@@ -90,20 +92,45 @@ function M.on_session_status(properties)
9092
return
9193
end
9294

93-
local active_session = state.active_session
94-
if active_session and active_session.id and properties.sessionID ~= active_session.id then
95+
if not properties.sessionID or not properties.status then
9596
return
9697
end
9798

98-
M._animation.status_data = properties.status
99+
M._animation.last_status_map[properties.sessionID] = properties.status
100+
101+
local active_session = state.active_session
102+
if active_session and active_session.id == properties.sessionID then
103+
M._animation.status_data = properties.status
104+
M._animation.status_session_id = properties.sessionID
105+
M.refresh()
106+
end
99107
M.render(state.windows)
100108
end
101109

102-
local function on_active_session_change(_, new_session, old_session)
110+
local function replay_status_for(session_id)
111+
local status = M._animation.last_status_map[session_id]
112+
if not status then
113+
return
114+
end
115+
local active_session = state.active_session
116+
if not active_session or active_session.id ~= session_id then
117+
return
118+
end
119+
M._animation.status_data = status
120+
M._animation.status_session_id = session_id
121+
M.refresh()
122+
M.render(state.windows)
123+
end
124+
125+
M._on_active_session_change = function(_, new_session, old_session)
103126
local new_id = new_session and new_session.id
104127
local old_id = old_session and old_session.id
105-
if new_id ~= old_id then
128+
if old_id and old_id ~= new_id then
106129
M._animation.status_data = nil
130+
M._animation.status_session_id = nil
131+
end
132+
if new_id then
133+
replay_status_for(new_id)
107134
end
108135
end
109136

@@ -138,8 +165,9 @@ M.render = vim.schedule_wrap(function(windows)
138165
return false
139166
end
140167

141-
if not state.jobs.is_running() then
142-
M.stop()
168+
M.refresh()
169+
170+
if not M.is_running() then
143171
return false
144172
end
145173

@@ -167,8 +195,8 @@ function M._start_animation_timer(windows)
167195
interval = interval,
168196
on_tick = function()
169197
M._animation.current_frame = M._next_frame()
170-
M.render(state.windows)
171-
if state.jobs.is_running() then
198+
M.render(windows)
199+
if M._should_animate() then
172200
return true
173201
else
174202
M.stop()
@@ -199,41 +227,84 @@ end
199227
function M.stop()
200228
M._clear_animation_timer()
201229
M._animation.current_frame = 1
202-
M._animation.status_data = nil
203230
if state.windows and state.windows.footer_buf and vim.api.nvim_buf_is_valid(state.windows.footer_buf) then
204231
pcall(vim.api.nvim_buf_clear_namespace, state.windows.footer_buf, M._animation.ns_id, 0, -1)
205232
end
206233
end
207234

235+
function M._should_animate()
236+
local status = M._animation.status_data
237+
if not status or status.type == 'idle' then
238+
return false
239+
end
240+
local active_session = state.active_session
241+
if not active_session then
242+
return false
243+
end
244+
return M._animation.status_session_id == active_session.id
245+
end
246+
247+
function M.sync_from_server()
248+
local api_client = state.api_client
249+
if not api_client or not api_client.list_session_status then
250+
return
251+
end
252+
253+
api_client
254+
:list_session_status(state.current_cwd or vim.fn.getcwd())
255+
:and_then(function(status_map)
256+
if type(status_map) ~= 'table' then
257+
return
258+
end
259+
for session_id, status in pairs(status_map) do
260+
if not M._animation.last_status_map[session_id] then
261+
M._animation.last_status_map[session_id] = status
262+
end
263+
end
264+
local active_session = state.active_session
265+
if active_session then
266+
replay_status_for(active_session.id)
267+
end
268+
end)
269+
:catch(function(err)
270+
require('opencode.log').debug('loading_animation.sync_from_server failed: %s', tostring(err))
271+
end)
272+
end
273+
208274
function M.is_running()
209275
return M._animation.timer ~= nil
210276
end
211277

212-
local function on_running_change(_, new_value)
278+
function M.refresh()
213279
if not state.windows then
214280
return
215281
end
216-
217-
if not M.is_running() and new_value and new_value > 0 then
218-
M.start(state.windows)
219-
else
282+
if M._should_animate() then
283+
if not M.is_running() then
284+
M.start(state.windows)
285+
end
286+
elseif M.is_running() then
220287
M.stop()
221288
end
222289
end
223290

224291
function M.setup()
225-
state.store.subscribe('job_count', on_running_change)
226-
state.store.subscribe('active_session', on_active_session_change)
292+
state.store.subscribe('job_count', M.refresh)
293+
state.store.subscribe('active_session', M._on_active_session_change)
227294
state.store.subscribe('event_manager', on_event_manager_change)
228295
subscribe_session_status_event(state.event_manager)
296+
M.sync_from_server()
229297
end
230298

231299
function M.teardown()
232-
state.store.unsubscribe('job_count', on_running_change)
233-
state.store.unsubscribe('active_session', on_active_session_change)
300+
state.store.unsubscribe('job_count', M.refresh)
301+
state.store.unsubscribe('active_session', M._on_active_session_change)
234302
state.store.unsubscribe('event_manager', on_event_manager_change)
235303
unsubscribe_session_status_event(M._animation.status_event_manager)
304+
M._animation.last_status_map = {}
236305
M._animation.status_data = nil
306+
M._animation.status_session_id = nil
307+
M._clear_animation_timer()
237308
end
238309

239310
return M

0 commit comments

Comments
 (0)