Skip to content
Draft
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
10 changes: 10 additions & 0 deletions lua/opencode/api_client.lua
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,16 @@ function OpencodeApiClient:list_sessions(directory)
return self:_call('/session', 'GET', nil, { directory = directory })
end

--- List the current status of all sessions in a workspace.
--- Useful for hydrating client-side state right after (re)connecting to a
--- server, since the SSE stream only delivers events that fire after the
--- subscription is established.
--- @param directory string|nil Directory path
--- @return Promise<{[string]: SessionStatusInfo}>
function OpencodeApiClient:list_session_status(directory)
return self:_call('/session/status', 'GET', nil, { directory = directory })
end

--- List sessions across all projects (experimental global endpoint).
--- Bypasses _call's automatic directory injection so the server returns all
--- directories instead of being filtered to the current cwd.
Expand Down
25 changes: 25 additions & 0 deletions lua/opencode/event_manager.lua
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,31 @@ function EventManager:_subscribe_to_server_events(server)
self.server_subscription = api_client:subscribe_to_events(directory, emitter)
end

--- Hydrate session.status listeners with the current server-side status.
--- The SSE stream is event-driven and does not replay past events, so a
--- fresh attach (e.g. after restarting vim while the server keeps working)
--- would otherwise miss the in-flight busy state. Polling /session/status
--- and re-emitting each non-idle entry restores parity.
--- @param api_client table
--- @param directory string
function EventManager:_sync_initial_session_status(api_client, directory)
if not api_client or not api_client.list_session_status then
return
end
api_client:list_session_status(directory):and_then(function(status_map)
if type(status_map) ~= 'table' then
return
end
for session_id, status in pairs(status_map) do
if type(status) == 'table' and status.type and status.type ~= 'idle' then
self:emit('session.status', { sessionID = session_id, status = status })
end
end
end):catch(function(err)
log.debug('Initial session status sync failed: %s', tostring(err))
end)
end

function EventManager:_cleanup_server_subscription()
if self.server_subscription then
pcall(function()
Expand Down
144 changes: 134 additions & 10 deletions lua/opencode/ui/loading_animation.lua
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
local state = require('opencode.state')
local config = require('opencode.config')
local log = require('opencode.log')

local Timer = require('opencode.ui.timer')
local M = {}
Expand All @@ -8,12 +9,17 @@ M._animation = {
frames = nil,
text = 'Thinking... ',
status_data = nil,
status_session_id = nil,
current_frame = 1,
timer = nil,
fps = 10,
extmark_id = nil,
ns_id = vim.api.nvim_create_namespace('opencode_loading_animation'),
status_event_manager = nil,
-- Cached response of the most recent /session/status call, keyed by
-- sessionID. Used to hydrate the spinner when active_session is set
-- *after* the sync resolves (Race B in commit message).
last_status_map = nil,
}

---@param status table|nil
Expand Down Expand Up @@ -91,19 +97,97 @@ function M.on_session_status(properties)
end

local active_session = state.active_session
if active_session and active_session.id and properties.sessionID ~= active_session.id then
if not active_session or not active_session.id then
return
end
if properties.sessionID ~= active_session.id then
return
end

M._animation.status_data = properties.status
M._animation.status_session_id = properties.sessionID

local status_type = properties.status and properties.status.type
if status_type == 'busy' and not M.is_running() and state.windows then
M.start(state.windows)
return
end

if status_type == 'idle' and not state.jobs.is_running() then
M.stop()
return
end

M.render(state.windows)
end

--- Internal: replay a status_map entry for the given sessionID through
--- `on_session_status`. Bails when active_session does not match.
local function replay_status_for(session_id)
local map = M._animation.last_status_map
if type(map) ~= 'table' then
return
end
local status = map[session_id]
if not status then
return
end
local active_session = state.active_session
if not active_session or active_session.id ~= session_id then
return
end
M.on_session_status({ sessionID = session_id, status = status })
end

--- Query the server for the current active session status and replay it
--- through `on_session_status` so the spinner can hydrate after a
--- (re)attach. Safe to call repeatedly; the response is also cached so
--- a late active_session assignment can replay without another round
--- trip.
function M.sync_from_server(directory)
local api_client = state.api_client
if not api_client or not api_client.list_session_status then
return
end
directory = directory or state.current_cwd or vim.fn.getcwd()
api_client:list_session_status(directory):and_then(function(status_map)
if type(status_map) ~= 'table' then
return
end
M._animation.last_status_map = status_map
local active_session = state.active_session
local active_id = active_session and active_session.id
if not active_id then
return
end
local status = status_map[active_id]
if not status then
return
end
M.on_session_status({ sessionID = active_id, status = status })
end):catch(function(err)
log.debug('loading_animation.sync_from_server failed: %s', tostring(err))
end)
end

local function on_active_session_change(_, new_session, old_session)
local new_id = new_session and new_session.id
local old_id = old_session and old_session.id
if new_id ~= old_id then
-- Only treat this as a session switch when there was a previous
-- non-nil session and it actually changed. The first assignment
-- (nil -> X) must NOT wipe status_data: sync_from_server may have
-- just installed X's busy status a few ticks earlier, and we want
-- the spinner to stay up. Clearing on the first set is what produced
-- the "spinner flashes and disappears" race.
if old_id and old_id ~= new_id then
M._animation.status_data = nil
M._animation.status_session_id = nil
end
-- If sync_from_server finished while active_session was still nil,
-- its callback was a no-op. Now that active_session is set, replay
-- the cached status_map entry for it.
if new_id then
replay_status_for(new_id)
end
end

Expand All @@ -116,6 +200,24 @@ function M._get_display_text()
return M._format_status_text(M._animation.status_data) or M._animation.text
end

function M._should_animate()
-- The spinner reflects the server's session.status, not local
-- in-flight HTTP requests. Counting jobs.is_running() here caused a
-- visible flash on every UI open: ensure_server, list_sessions,
-- and sync_from_status each bump job_count, and on_running_change
-- would start the spinner for a few frames until the response
-- decremented it back. Spinner now only follows server state.
local status = M._animation.status_data
if not status or status.type == 'idle' then
return false
end
local active_session = state.active_session
if not active_session or not active_session.id then
return false
end
return M._animation.status_session_id == active_session.id
end

function M._get_frames()
if M._animation.frames then
return M._animation.frames
Expand All @@ -128,7 +230,7 @@ function M._get_frames()
return { '⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏' }
end

M.render = vim.schedule_wrap(function(windows)
function M._render_immediate(windows)
windows = windows or state.windows
if not windows or not windows.output_buf or not windows.footer_buf then
return false
Expand All @@ -138,7 +240,7 @@ M.render = vim.schedule_wrap(function(windows)
return false
end

if not state.jobs.is_running() then
if not M._should_animate() then
M.stop()
return false
end
Expand All @@ -153,7 +255,9 @@ M.render = vim.schedule_wrap(function(windows)
})

return true
end)
end

M.render = vim.schedule_wrap(M._render_immediate)

function M._next_frame()
return (M._animation.current_frame % #M._get_frames()) + 1
Expand All @@ -168,7 +272,7 @@ function M._start_animation_timer(windows)
on_tick = function()
M._animation.current_frame = M._next_frame()
M.render(state.windows)
if state.jobs.is_running() then
if M._should_animate() then
return true
else
M.stop()
Expand All @@ -193,13 +297,14 @@ function M.start(windows)
return
end
M._start_animation_timer(windows)
M.render(windows)
M._render_immediate(windows)
end

function M.stop()
M._clear_animation_timer()
M._animation.current_frame = 1
M._animation.status_data = nil
M._animation.status_session_id = nil
if state.windows and state.windows.footer_buf and vim.api.nvim_buf_is_valid(state.windows.footer_buf) then
pcall(vim.api.nvim_buf_clear_namespace, state.windows.footer_buf, M._animation.ns_id, 0, -1)
end
Expand All @@ -214,8 +319,10 @@ local function on_running_change(_, new_value)
return
end

if not M.is_running() and new_value and new_value > 0 then
M.start(state.windows)
if M._should_animate() then
if not M.is_running() then
M.start(state.windows)
end
else
M.stop()
end
Expand All @@ -226,14 +333,31 @@ function M.setup()
state.store.subscribe('active_session', on_active_session_change)
state.store.subscribe('event_manager', on_event_manager_change)
subscribe_session_status_event(state.event_manager)

-- Pull the current server-side status so that on first attach (where
-- the SSE stream cannot replay past events) we still know whether the
-- active session is currently busy. This is also the recovery path
-- for the cases where the spinner needs to come up after a toggle.
--
-- We deliberately do NOT start the spinner from preserved
-- status_data here: that data can be stale (the server may have
-- finished thinking while the UI was toggled off, and SSE cannot
-- replay past events). Starting on stale data caused a one-frame
-- flicker when the server was actually idle. Sync's replay decides
-- whether to M.start, so the spinner is always driven by the
-- server's current state.
M.sync_from_server()
end

function M.teardown()
state.store.unsubscribe('job_count', on_running_change)
state.store.unsubscribe('active_session', on_active_session_change)
state.store.unsubscribe('event_manager', on_event_manager_change)
unsubscribe_session_status_event(M._animation.status_event_manager)
M._animation.status_data = nil
-- Keep status_data and status_session_id intact: the server-side state
-- does not change just because the UI was toggled off. The next setup
-- can resume the spinner from the preserved value if it still applies.
M._clear_animation_timer()
end

return M
83 changes: 83 additions & 0 deletions tests/unit/event_manager_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -328,4 +328,87 @@ describe('EventManager', function()
assert.is_true(autocmd_called)
end)
end)

describe('initial session status sync', function()
it('emits session.status for non-idle entries returned by api_client', function()
local status_map = {
ses_busy = { type = 'busy' },
ses_idle = { type = 'idle' },
ses_retry = {
type = 'retry',
message = 'overloaded',
attempt = 1,
next = 0,
},
}
local fake_api_client = {
list_session_status = function(_, _directory)
local p = Promise.new()
p:resolve(status_map)
return p
end,
}

local received = {}
event_manager:subscribe('session.status', function(data)
table.insert(received, data)
end)

event_manager:_sync_initial_session_status(fake_api_client, '/work')

vim.wait(200, function()
return #received >= 2
end)

local session_ids = {}
for _, entry in ipairs(received) do
session_ids[entry.sessionID] = entry.status
end

assert.are.same({ type = 'busy' }, session_ids.ses_busy)
assert.are.same(
{ type = 'retry', message = 'overloaded', attempt = 1, next = 0 },
session_ids.ses_retry
)
assert.is_nil(session_ids.ses_idle)
end)

it('does nothing when api_client has no list_session_status', function()
local received = 0
event_manager:subscribe('session.status', function()
received = received + 1
end)

event_manager:_sync_initial_session_status({}, '/work')

vim.wait(50, function()
return received > 0
end)

assert.are.equal(0, received)
end)

it('swallows errors from a failing list_session_status', function()
local fake_api_client = {
list_session_status = function(_, _directory)
local p = Promise.new()
p:reject('boom')
return p
end,
}

local received = 0
event_manager:subscribe('session.status', function()
received = received + 1
end)

event_manager:_sync_initial_session_status(fake_api_client, '/work')

vim.wait(50, function()
return false
end)

assert.are.equal(0, received)
end)
end)
end)
Loading
Loading