Detailed changes
@@ -32,6 +32,7 @@ var (
ErrInvalidPermissionAction = errors.New("invalid permission action")
ErrUnknownCommand = errors.New("unknown command")
ErrInvalidClientID = errors.New("invalid client_id")
+ ErrClientNotAttached = errors.New("client not attached")
)
// DefaultCreateGrace is the window in which a client must open an SSE
@@ -77,13 +78,18 @@ type Backend struct {
// - holdTimer is non-nil iff the client created the workspace but has
// not yet attached an SSE stream; it fires after createGrace and
// releases the hold.
+// - currentSessionID records which session this client is currently
+// viewing. Empty string means the client has no session selected
+// (e.g. the landing screen). Cleared automatically when the
+// clientState entry is removed.
//
-// The two are mutually exclusive in practice (the hold timer is stopped
-// the moment an SSE stream attaches), but both being zero/nil means the
-// entry has been released and should be removed.
+// streams and holdTimer are mutually exclusive in practice (the hold
+// timer is stopped the moment an SSE stream attaches), but both being
+// zero/nil means the entry has been released and should be removed.
type clientState struct {
- streams int
- holdTimer *time.Timer
+ streams int
+ holdTimer *time.Timer
+ currentSessionID string
}
// Workspace represents a running [app.App] workspace with its
@@ -468,6 +474,39 @@ func (b *Backend) DeleteWorkspace(id, clientID string) error {
return b.releaseHold(id, clientID)
}
+// SetCurrentSession records which session the given client is
+// currently viewing within the workspace. Passing an empty sessionID
+// clears the client's current-session entry (e.g. the client has
+// returned to the landing screen).
+//
+// The client must be actually attached — i.e. its [clientState] entry
+// must exist and have at least one live stream. A bare creation hold
+// (streams == 0) is rejected with [ErrClientNotAttached]. This
+// guards against zombie writes from a client that has detached and
+// against ghost presence from a hold-only client that never opened an
+// SSE stream.
+func (b *Backend) SetCurrentSession(workspaceID, clientID, sessionID string) error {
+ if _, err := validateClientID(clientID); err != nil {
+ return err
+ }
+ ws, ok := b.workspaces.Get(workspaceID)
+ if !ok {
+ return ErrWorkspaceNotFound
+ }
+ ws.clientsMu.Lock()
+ defer ws.clientsMu.Unlock()
+ cs, ok := ws.clients[clientID]
+ if !ok || cs.streams == 0 {
+ // No entry, or hold-only (no live stream): refuse the
+ // write. The presence record this is meant to feed
+ // should only reflect clients that can actually observe
+ // session events.
+ return ErrClientNotAttached
+ }
+ cs.currentSessionID = sessionID
+ return nil
+}
+
// GetWorkspaceProto returns the proto representation of a workspace.
func (b *Backend) GetWorkspaceProto(id string) (proto.Workspace, error) {
ws, err := b.GetWorkspace(id)
@@ -951,3 +951,184 @@ func TestAttachClient_RacesWithTeardown(t *testing.T) {
}
}
}
+
+// TestSetCurrentSession_BasicAttachAndSwitch verifies the happy path:
+// an attached client can set its current session, a second attached
+// client can target the same session, and one of them can switch to a
+// different session without disturbing the other's record.
+func TestSetCurrentSession_BasicAttachAndSwitch(t *testing.T) {
+ t.Parallel()
+
+ b, _ := newTestBackend(t)
+ ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-basic")
+
+ cidA := newClientID(t)
+ cidB := newClientID(t)
+ require.NoError(t, b.AttachClient(ws.ID, cidA))
+ require.NoError(t, b.AttachClient(ws.ID, cidB))
+
+ require.NoError(t, b.SetCurrentSession(ws.ID, cidA, "S1"))
+ ws.clientsMu.Lock()
+ require.Equal(t, "S1", ws.clients[cidA].currentSessionID)
+ ws.clientsMu.Unlock()
+
+ require.NoError(t, b.SetCurrentSession(ws.ID, cidB, "S1"))
+ ws.clientsMu.Lock()
+ require.Equal(t, "S1", ws.clients[cidA].currentSessionID)
+ require.Equal(t, "S1", ws.clients[cidB].currentSessionID)
+ ws.clientsMu.Unlock()
+
+ // B switches to S2; counts redistribute.
+ require.NoError(t, b.SetCurrentSession(ws.ID, cidB, "S2"))
+ ws.clientsMu.Lock()
+ require.Equal(t, "S1", ws.clients[cidA].currentSessionID)
+ require.Equal(t, "S2", ws.clients[cidB].currentSessionID)
+ ws.clientsMu.Unlock()
+
+ // A clears its selection.
+ require.NoError(t, b.SetCurrentSession(ws.ID, cidA, ""))
+ ws.clientsMu.Lock()
+ require.Empty(t, ws.clients[cidA].currentSessionID)
+ require.Equal(t, "S2", ws.clients[cidB].currentSessionID)
+ ws.clientsMu.Unlock()
+
+ // Drain to release the workspace.
+ b.DetachClient(ws.ID, cidA)
+ b.DetachClient(ws.ID, cidB)
+}
+
+// TestSetCurrentSession_DetachClearsEntry verifies the implicit
+// cleanup: once a client's [clientState] entry is removed (last
+// stream closed), its currentSessionID is gone with it.
+func TestSetCurrentSession_DetachClearsEntry(t *testing.T) {
+ t.Parallel()
+
+ b, _ := newTestBackend(t)
+ ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-detach")
+
+ // Anchor client so the workspace is not torn down when cid
+ // detaches.
+ anchor := newClientID(t)
+ require.NoError(t, b.AttachClient(ws.ID, anchor))
+
+ cid := newClientID(t)
+ require.NoError(t, b.AttachClient(ws.ID, cid))
+ require.NoError(t, b.SetCurrentSession(ws.ID, cid, "S2"))
+
+ b.DetachClient(ws.ID, cid)
+
+ ws.clientsMu.Lock()
+ _, present := ws.clients[cid]
+ ws.clientsMu.Unlock()
+ require.False(t, present, "detach must remove the clientState entry along with its currentSessionID")
+
+ // A follow-up SetCurrentSession on the gone client must be
+ // rejected with ErrClientNotAttached.
+ require.ErrorIs(t, b.SetCurrentSession(ws.ID, cid, "S3"), ErrClientNotAttached)
+
+ b.DetachClient(ws.ID, anchor)
+}
+
+// TestSetCurrentSession_RejectsHoldOnly verifies that a registered
+// client whose only claim is a creation hold (streams == 0) cannot
+// influence presence: SetCurrentSession returns ErrClientNotAttached
+// and the entry's currentSessionID stays empty.
+func TestSetCurrentSession_RejectsHoldOnly(t *testing.T) {
+ t.Parallel()
+
+ b, _ := newTestBackend(t)
+ // Keep the grace window large so the hold survives the test.
+ b.createGrace = time.Hour
+ ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-hold")
+
+ cid := newClientID(t)
+ b.registerClient(ws, cid)
+
+ require.ErrorIs(t, b.SetCurrentSession(ws.ID, cid, "S1"), ErrClientNotAttached)
+
+ ws.clientsMu.Lock()
+ require.Empty(t, ws.clients[cid].currentSessionID, "hold-only client must not write a session id")
+ ws.clientsMu.Unlock()
+
+ // Drain.
+ require.NoError(t, b.releaseHold(ws.ID, cid))
+}
+
+// TestSetCurrentSession_UnknownClient verifies that a client with no
+// entry at all is rejected with ErrClientNotAttached.
+func TestSetCurrentSession_UnknownClient(t *testing.T) {
+ t.Parallel()
+
+ b, _ := newTestBackend(t)
+ ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-unknown")
+
+ require.ErrorIs(t, b.SetCurrentSession(ws.ID, newClientID(t), "S1"), ErrClientNotAttached)
+}
+
+// TestSetCurrentSession_RejectsBadInputs covers the validation
+// branches: empty/malformed client_id and unknown workspace.
+func TestSetCurrentSession_RejectsBadInputs(t *testing.T) {
+ t.Parallel()
+
+ b, _ := newTestBackend(t)
+ ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-bad")
+
+ require.ErrorIs(t, b.SetCurrentSession(ws.ID, "", "S1"), ErrInvalidClientID)
+ require.ErrorIs(t, b.SetCurrentSession(ws.ID, "not-a-uuid", "S1"), ErrInvalidClientID)
+
+ require.ErrorIs(
+ t,
+ b.SetCurrentSession("00000000-0000-0000-0000-000000000000", newClientID(t), "S1"),
+ ErrWorkspaceNotFound,
+ )
+}
+
+// TestSetCurrentSession_RaceWithDetach exercises concurrent
+// SetCurrentSession updates from one client racing against detach
+// on a second client. The final state must be self-consistent: any
+// remaining clientState entries reflect a coherent
+// (streams, currentSessionID) pair.
+func TestSetCurrentSession_RaceWithDetach(t *testing.T) {
+ t.Parallel()
+
+ b, _ := newTestBackend(t)
+ ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-race")
+
+ cidA := newClientID(t)
+ cidB := newClientID(t)
+ require.NoError(t, b.AttachClient(ws.ID, cidA))
+ require.NoError(t, b.AttachClient(ws.ID, cidB))
+
+ var wg sync.WaitGroup
+ const updates = 200
+ wg.Add(3)
+ go func() {
+ defer wg.Done()
+ for i := range updates {
+ // Errors are tolerated: once cidA detaches,
+ // further updates against cidA must return
+ // ErrClientNotAttached but never panic.
+ _ = b.SetCurrentSession(ws.ID, cidA, "SA")
+ _ = i
+ }
+ }()
+ go func() {
+ defer wg.Done()
+ for i := range updates {
+ _ = b.SetCurrentSession(ws.ID, cidB, "SB")
+ _ = i
+ }
+ }()
+ go func() {
+ defer wg.Done()
+ // Single concurrent detach of cidA partway through.
+ b.DetachClient(ws.ID, cidA)
+ }()
+ wg.Wait()
+
+ ws.clientsMu.Lock()
+ defer ws.clientsMu.Unlock()
+ require.NotContains(t, ws.clients, cidA, "detached client must be gone")
+ require.Contains(t, ws.clients, cidB, "remaining client must still be present")
+ require.Equal(t, "SB", ws.clients[cidB].currentSessionID, "remaining client must keep its last set session")
+}
@@ -18,3 +18,15 @@ func InsertWorkspaceForTest(b *Backend, ws *Workspace) {
b.pathIndex[ws.resolvedPath] = ws.ID
}
}
+
+// RegisterClientForTesting installs a creation hold for clientID on
+// ws using the backend's normal registerClient path. Intended for
+// tests in other packages that need to drive a hold-only client
+// (streams == 0) without booting a real CreateWorkspace flow.
+func RegisterClientForTesting(b *Backend, ws *Workspace, clientID string) error {
+ if _, err := validateClientID(clientID); err != nil {
+ return err
+ }
+ b.registerClient(ws, clientID)
+ return nil
+}
@@ -86,6 +86,30 @@ func (c *Client) DeleteWorkspace(ctx context.Context, id string) error {
return nil
}
+// SetCurrentSession reports the client's current-session selection
+// for the named workspace. An empty sessionID clears the entry. The
+// request carries the process-scoped client ID minted in [NewClient]
+// as a query parameter so the server can route the update to the
+// correct [clientState] entry.
+func (c *Client) SetCurrentSession(ctx context.Context, workspaceID, sessionID string) error {
+ q := url.Values{"client_id": []string{c.clientID}}
+ rsp, err := c.post(
+ ctx,
+ fmt.Sprintf("/workspaces/%s/current-session", workspaceID),
+ q,
+ jsonBody(proto.CurrentSession{SessionID: sessionID}),
+ http.Header{"Content-Type": []string{"application/json"}},
+ )
+ if err != nil {
+ return fmt.Errorf("failed to set current session: %w", err)
+ }
+ defer rsp.Body.Close()
+ if rsp.StatusCode != http.StatusOK {
+ return fmt.Errorf("failed to set current session: status code %d", rsp.StatusCode)
+ }
+ return nil
+}
+
// SubscribeEvents subscribes to server-sent events for a workspace.
func (c *Client) SubscribeEvents(ctx context.Context, id string) (<-chan any, error) {
events := make(chan any, 100)
@@ -36,6 +36,12 @@ type ConfigChanged struct {
WorkspaceID string `json:"workspace_id"`
}
+// CurrentSession is the request body for the per-client
+// current-session endpoint. An empty SessionID clears the entry.
+type CurrentSession struct {
+ SessionID string `json:"session_id"`
+}
+
// AgentInfo represents information about the agent.
type AgentInfo struct {
IsBusy bool `json:"is_busy"`
@@ -10,9 +10,26 @@ import (
"github.com/charmbracelet/crush/internal/backend"
"github.com/charmbracelet/crush/internal/proto"
+ "github.com/google/uuid"
"github.com/stretchr/testify/require"
)
+// installSyntheticWorkspace creates a synthetic [backend.Workspace]
+// registered with the controller's backend, suitable for handler-level
+// tests that do not need a real [app.App]. The workspace's ID is a
+// fresh UUID and its path is a tempdir; teardown is the caller's
+// responsibility (handlers should not rely on synthetic workspaces
+// disappearing automatically).
+func installSyntheticWorkspace(t *testing.T, c *controllerV1) *backend.Workspace {
+ t.Helper()
+ ws := &backend.Workspace{
+ ID: uuid.New().String(),
+ Path: t.TempDir(),
+ }
+ backend.InsertWorkspaceForTest(c.backend, ws)
+ return ws
+}
+
// newTestController builds a controllerV1 around a backend without a
// real config store, suitable for handler-level 400 tests.
func newTestController() *controllerV1 {
@@ -105,3 +122,109 @@ func TestSubscribeEvents_RejectsMalformedClientID(t *testing.T) {
require.Equal(t, http.StatusBadRequest, rec.Code)
}
+
+// postCurrentSession is a small helper that POSTs the JSON body to
+// /v1/workspaces/{id}/current-session?client_id=cid and returns the
+// recorder. It does not require a real listener.
+func postCurrentSession(t *testing.T, c *controllerV1, wsID, clientID, sessionID string) *httptest.ResponseRecorder {
+ t.Helper()
+ body, err := json.Marshal(proto.CurrentSession{SessionID: sessionID})
+ require.NoError(t, err)
+ url := "/v1/workspaces/" + wsID + "/current-session"
+ if clientID != "" {
+ url += "?client_id=" + clientID
+ }
+ req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, url, bytes.NewReader(body))
+ req.SetPathValue("id", wsID)
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ c.handlePostWorkspaceCurrentSession(rec, req)
+ return rec
+}
+
+func TestPostCurrentSession_RejectsMissingClientID(t *testing.T) {
+ t.Parallel()
+ c := newTestController()
+
+ body, err := json.Marshal(proto.CurrentSession{SessionID: "S1"})
+ require.NoError(t, err)
+ req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/v1/workspaces/abc/current-session", bytes.NewReader(body))
+ req.SetPathValue("id", "abc")
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+
+ c.handlePostWorkspaceCurrentSession(rec, req)
+
+ require.Equal(t, http.StatusBadRequest, rec.Code)
+}
+
+func TestPostCurrentSession_RejectsMalformedClientID(t *testing.T) {
+ t.Parallel()
+ c := newTestController()
+
+ rec := postCurrentSession(t, c, "abc", "not-a-uuid", "S1")
+ require.Equal(t, http.StatusBadRequest, rec.Code)
+}
+
+func TestPostCurrentSession_RejectsBadBody(t *testing.T) {
+ t.Parallel()
+ c := newTestController()
+
+ cid := uuid.New().String()
+ url := "/v1/workspaces/abc/current-session?client_id=" + cid
+ req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, url, bytes.NewReader([]byte("not-json")))
+ req.SetPathValue("id", "abc")
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+
+ c.handlePostWorkspaceCurrentSession(rec, req)
+
+ require.Equal(t, http.StatusBadRequest, rec.Code)
+}
+
+func TestPostCurrentSession_UnknownWorkspace(t *testing.T) {
+ t.Parallel()
+ c := newTestController()
+
+ rec := postCurrentSession(t, c, uuid.New().String(), uuid.New().String(), "S1")
+ require.Equal(t, http.StatusNotFound, rec.Code)
+}
+
+func TestPostCurrentSession_UnknownClient(t *testing.T) {
+ t.Parallel()
+ c := newTestController()
+ ws := installSyntheticWorkspace(t, c)
+
+ rec := postCurrentSession(t, c, ws.ID, uuid.New().String(), "S1")
+ require.Equal(t, http.StatusNotFound, rec.Code)
+}
+
+func TestPostCurrentSession_HoldOnly(t *testing.T) {
+ t.Parallel()
+ c := newTestController()
+ ws := installSyntheticWorkspace(t, c)
+
+ cid := uuid.New().String()
+ require.NoError(t, backend.RegisterClientForTesting(c.backend, ws, cid))
+ t.Cleanup(func() { _ = c.backend.DeleteWorkspace(ws.ID, cid) })
+
+ rec := postCurrentSession(t, c, ws.ID, cid, "S1")
+ require.Equal(t, http.StatusNotFound, rec.Code, "hold-only client must be rejected")
+}
+
+func TestPostCurrentSession_AttachedClientSucceeds(t *testing.T) {
+ t.Parallel()
+ c := newTestController()
+ ws := installSyntheticWorkspace(t, c)
+
+ cid := uuid.New().String()
+ require.NoError(t, c.backend.AttachClient(ws.ID, cid))
+ t.Cleanup(func() { c.backend.DetachClient(ws.ID, cid) })
+
+ rec := postCurrentSession(t, c, ws.ID, cid, "S1")
+ require.Equal(t, http.StatusOK, rec.Code)
+
+ // Clearing also returns 200.
+ rec = postCurrentSession(t, c, ws.ID, cid, "")
+ require.Equal(t, http.StatusOK, rec.Code)
+}
@@ -151,6 +151,39 @@ func (c *controllerV1) requireClientID(w http.ResponseWriter, r *http.Request) (
return cid, true
}
+// handlePostWorkspaceCurrentSession records the calling client's
+// current session selection for the workspace. An empty session_id
+// clears the entry (e.g. the client is on the landing screen).
+//
+// @Summary Set current session for a client
+// @Tags workspaces
+// @Accept json
+// @Produce json
+// @Param id path string true "Workspace ID"
+// @Param client_id query string true "Client ID (UUID)"
+// @Param request body proto.CurrentSession true "Current session selection"
+// @Success 200
+// @Failure 400 {object} proto.Error
+// @Failure 404 {object} proto.Error
+// @Router /workspaces/{id}/current-session [post]
+func (c *controllerV1) handlePostWorkspaceCurrentSession(w http.ResponseWriter, r *http.Request) {
+ id := r.PathValue("id")
+ clientID, ok := c.requireClientID(w, r)
+ if !ok {
+ return
+ }
+ var req proto.CurrentSession
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ c.server.logError(r, "Failed to decode request", "error", err)
+ jsonError(w, http.StatusBadRequest, "failed to decode request")
+ return
+ }
+ if err := c.backend.SetCurrentSession(id, clientID, req.SessionID); err != nil {
+ c.handleError(w, r, err)
+ return
+ }
+}
+
// handleDeleteWorkspaces deletes a workspace.
//
// @Summary Delete workspace
@@ -1005,6 +1038,8 @@ func (c *controllerV1) handleError(w http.ResponseWriter, r *http.Request, err e
status = http.StatusBadRequest
case errors.Is(err, backend.ErrInvalidClientID):
status = http.StatusBadRequest
+ case errors.Is(err, backend.ErrClientNotAttached):
+ status = http.StatusNotFound
}
c.server.logError(r, err.Error())
jsonError(w, status, err.Error())
@@ -113,6 +113,7 @@ func NewServer(cfg *config.ConfigStore, network, address string) *Server {
mux.HandleFunc("GET /v1/workspaces", c.handleGetWorkspaces)
mux.HandleFunc("POST /v1/workspaces", c.handlePostWorkspaces)
mux.HandleFunc("DELETE /v1/workspaces/{id}", c.handleDeleteWorkspaces)
+ mux.HandleFunc("POST /v1/workspaces/{id}/current-session", c.handlePostWorkspaceCurrentSession)
mux.HandleFunc("GET /v1/workspaces/{id}", c.handleGetWorkspace)
mux.HandleFunc("GET /v1/workspaces/{id}/config", c.handleGetWorkspaceConfig)
mux.HandleFunc("GET /v1/workspaces/{id}/events", c.handleGetWorkspaceEvents)
@@ -64,8 +64,13 @@ type SessionFile struct {
// the diff statistics (additions and deletions) for each file in the session.
// It returns a tea.Cmd that, when executed, fetches the session data and
// returns a sessionFilesLoadedMsg containing the processed session files.
+//
+// The returned batch also reports the new current-session selection to
+// the workspace so the server can update its per-client presence map.
+// That report is fire-and-forget: errors are logged at debug and the
+// UI never blocks on the call.
func (m *UI) loadSession(sessionID string) tea.Cmd {
- return func() tea.Msg {
+ load := func() tea.Msg {
session, err := m.com.Workspace.GetSession(context.Background(), sessionID)
if err != nil {
return util.ReportError(err)
@@ -87,6 +92,21 @@ func (m *UI) loadSession(sessionID string) tea.Cmd {
readFiles: readFiles,
}
}
+ return tea.Batch(load, m.reportCurrentSession(sessionID))
+}
+
+// reportCurrentSession returns a fire-and-forget tea.Cmd that
+// informs the workspace which session this client is currently
+// viewing. Errors are logged at debug only; the call is a hint
+// for server-side presence tracking, not correctness-critical
+// state.
+func (m *UI) reportCurrentSession(sessionID string) tea.Cmd {
+ return func() tea.Msg {
+ if err := m.com.Workspace.SetCurrentSession(context.Background(), sessionID); err != nil {
+ slog.Debug("Failed to report current session", "session_id", sessionID, "error", err)
+ }
+ return nil
+ }
}
func (m *UI) loadSessionFiles(sessionID string) ([]SessionFile, error) {
@@ -3478,6 +3478,7 @@ func (m *UI) newSession() tea.Cmd {
return nil
},
m.loadPromptHistory(),
+ m.reportCurrentSession(""),
)
}
@@ -67,6 +67,13 @@ func (w *AppWorkspace) ParseAgentToolSessionID(sessionID string) (string, string
return w.app.Sessions.ParseAgentToolSessionID(sessionID)
}
+// SetCurrentSession is a no-op in single-client local mode. The
+// presence concept only matters when multiple clients can share a
+// workspace via the HTTP server.
+func (w *AppWorkspace) SetCurrentSession(ctx context.Context, sessionID string) error {
+ return nil
+}
+
// -- Messages --
func (w *AppWorkspace) ListMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
@@ -131,6 +131,14 @@ func (w *ClientWorkspace) ParseAgentToolSessionID(sessionID string) (string, str
return parts[0], parts[1], true
}
+// SetCurrentSession reports the session this client is currently
+// viewing to the server. Empty sessionID clears the entry. Errors
+// are propagated to the caller; the TUI logs and ignores them since
+// the presence record is a hint, not correctness-critical state.
+func (w *ClientWorkspace) SetCurrentSession(ctx context.Context, sessionID string) error {
+ return w.client.SetCurrentSession(ctx, w.workspaceID(), sessionID)
+}
+
// -- Messages --
func (w *ClientWorkspace) ListMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
@@ -67,6 +67,12 @@ type Workspace interface {
DeleteSession(ctx context.Context, sessionID string) error
CreateAgentToolSessionID(messageID, toolCallID string) string
ParseAgentToolSessionID(sessionID string) (messageID string, toolCallID string, ok bool)
+ // SetCurrentSession reports the session this client is currently
+ // viewing. Empty sessionID clears the entry (e.g. landing screen).
+ // In single-client local mode this is a no-op. In client/server
+ // mode it informs the server's per-client presence map so other
+ // observers can compute attached-client counts per session.
+ SetCurrentSession(ctx context.Context, sessionID string) error
// Messages
ListMessages(ctx context.Context, sessionID string) ([]message.Message, error)