Skip to content

refactor: migrate frame_selection and chat from Socket.IO to REST#835

Merged
PythonFZ merged 7 commits intomainfrom
feat/rest-frame-selection-chat
Jan 13, 2026
Merged

refactor: migrate frame_selection and chat from Socket.IO to REST#835
PythonFZ merged 7 commits intomainfrom
feat/rest-frame-selection-chat

Conversation

@PythonFZ
Copy link
Copy Markdown
Member

@PythonFZ PythonFZ commented Jan 11, 2026

  • Add PUT /api/rooms/{room}/frame-selection endpoint
  • Add POST /api/rooms/{room}/chat/messages endpoint
  • Add PATCH /api/rooms/{room}/chat/messages/{id} endpoint
  • Add APIManager methods for frame selection and chat
  • Update ZnDraw client to use REST instead of Socket.IO
  • Remove Socket.IO event handlers (frame_selection:set, chat:message:create, chat:message:edit)
  • Add skip_sid to broadcasts to avoid UI flicker for the caller
  • Update test to expect HTTPError on 403 instead of response dict

Socket.IO is now used only for broadcasts, not for request/response patterns.

Summary by CodeRabbit

  • New Features

    • REST endpoints for updating frame selection and for creating/editing chat messages; client APIs and UI hooks added to call them.
  • Improvements

    • Frame selection now uses optimistic local updates with server persistence.
    • Chat create/edit flows use authenticated API calls and enforce ownership.
  • Behavior

    • Socket handlers replaced by API-driven flows; real-time broadcasts continue to notify room collaborators.
  • Tests

    • Unauthorized edit path now surfaces a 403 HTTP error in tests.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jan 11, 2026

📝 Walkthrough

Walkthrough

Moves frame-selection and chat handling from Socket.IO to REST: adds server REST endpoints and broadcasts, client API methods (Python/TS), removes socket handlers, and updates frontend store/hooks/components and tests to use HTTP calls with real-time room broadcasts.

Changes

Cohort / File(s) Summary
API Manager (Python)
src/zndraw/api_manager.py
Added update_frame_selection, create_chat_message, edit_chat_message — build auth headers, perform HTTP requests (10s timeout), raise on HTTP errors, return parsed JSON.
Server REST: Frame Selection
src/zndraw/app/geometry_routes.py
New PUT /api/rooms/<room_id>/frame-selection: validate indices, persist JSON to Redis, broadcast FRAME_SELECTION_UPDATE, return {"success": True}.
Server REST: Chat
src/zndraw/app/screenshot_chat_routes.py
New POST /api/rooms/<room_id>/chat/messages and PATCH /api/rooms/<room_id>/chat/messages/<message_id>: require auth, validate content, create/edit via chat utils, enforce ownership on edits, broadcast CHAT_MESSAGE_NEW / CHAT_MESSAGE_UPDATED, return JSON.
Socket Event Handlers Removed
src/zndraw/app/events.py
Removed socket handlers: handle_frame_selection_set, handle_chat_message_create, handle_chat_message_edit.
Client Integration (Python)
src/zndraw/zndraw.py
frame_selection setter now calls API update_frame_selection; log() and edit_message() now call API create_chat_message / edit_chat_message and return dicts.
Constants
src/zndraw/app/constants.py
Added FRAME_SELECTION_UPDATE = "frame_selection:update" to SocketEvents.
Frontend API / Hooks (TypeScript)
app/src/myapi/client.ts, app/src/hooks/useChat.ts
Added updateFrameSelection, createChatMessage, editChatMessage client functions/types; useSendMessage / useEditMessage now call API functions instead of emitting via socket.
Frontend Store & Components
app/src/store.tsx, app/src/components/FigureWindow.tsx
Exposed updateFrameSelection on store (optimistic local update + API call); replaced setFrameSelection usages with updateFrameSelection in FigureWindow; adjusted guards/deps.
Tests
tests/test_chat.py, tests/test_vis_settings.py
test_chat_message_edit_unauthorized now expects requests.HTTPError (403); test_settings uses connect_room fixture to keep WS open and derive headers/room/session.

Sequence Diagram

sequenceDiagram
    participant Frontend as Client (frontend / zndraw)
    participant APIClient as API client (JS/Python)
    participant Server as REST API
    participant Redis
    participant Broadcaster as Socket.IO Broadcast

    Frontend->>APIClient: createChatMessage / editChatMessage / updateFrameSelection
    APIClient->>Server: POST / PATCH / PUT /api/rooms/{room}/... (auth headers + body)
    Server->>Redis: validate and persist chat message or frame_selection
    Server->>Broadcaster: emit room event (skip caller)
    Broadcaster-->>Frontend: realtime update delivered to room participants
    Server-->>APIClient: JSON response (success + resource)
    APIClient-->>Frontend: return parsed JSON
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • Feat/camera geom #834 — Modifies src/zndraw/api_manager.py; likely related to additions/changes in APIManager methods.

Poem

🐰 I hopped from sockets to tidy REST,

I carried frames and messages in my nest,
I tucked them in Redis, then gave a tiny shout,
The room heard the echo and spread it about,
Cheers and carrots for each successful route.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely describes the main change: migrating frame_selection and chat functionality from Socket.IO to REST API.
Docstring Coverage ✅ Passed Docstring coverage is 95.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

📜 Recent review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a2afb93 and 3626656.

📒 Files selected for processing (1)
  • tests/test_vis_settings.py
🧰 Additional context used
📓 Path-based instructions (2)
**/*.py

📄 CodeRabbit inference engine (AGENTS.md)

**/*.py: You MUST NEVER @pytest.mark.xfail or similar - all tests must pass!
All default values (e.g., camera, particles) must be defined exclusively within the Pydantic model. Do not scatter fallback logic throughout the codebase.
Do not perform null checks combined with hardcoded literals for default values. Rely entirely on the schema to populate default values during initialization.
You can not use LUA scripts with Redis!
If sensible, implement collections.abc interfaces for your classes, such as MutableMapping or MutableSequence.
Use numpy style docstrings in Python code.
Docstrings must be concise and to the point.
Use type hints wherever possible in Python. Use list[int|float] | None instead of t.Optional[t.List[int|float]]!
Imports should always be at the top of the file in Python.

Files:

  • tests/test_vis_settings.py
**/test_*.py

📄 CodeRabbit inference engine (AGENTS.md)

When designing new tests, read the old tests first to understand the existing patterns. Use pytest.mark.parametrize to avoid code duplication. Tests should be very specific and test only one thing. Avoid complex test setups. Each test must be a function, not a method of a class!

Files:

  • tests/test_vis_settings.py
🧬 Code graph analysis (1)
tests/test_vis_settings.py (1)
tests/conftest.py (2)
  • server (419-425)
  • connect_room (269-294)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: pytest (3.12, ubuntu-latest)
  • GitHub Check: pytest (3.11, ubuntu-latest)
  • GitHub Check: pytest (3.13, ubuntu-latest)
🔇 Additional comments (1)
tests/test_vis_settings.py (1)

96-124: LGTM! Correct fix for race condition.

The migration from join_room_and_get_headers to connect_room properly addresses the flaky test issue by keeping the WebSocket connection alive during the test. This follows the same pattern already established in other tests within this file (e.g., test_settings_endpoint_partial_update, test_settings_isolation_same_room_same_session).

The fixture provides automatic cleanup via its yield pattern, so no manual disconnect is needed.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (3)
src/zndraw/app/screenshot_chat_routes.py (2)

22-31: Extract _get_caller_sid to a shared utility module.

This helper function is duplicated in src/zndraw/app/geometry_routes.py (lines 20-29). Consider extracting it to a shared module (e.g., route_utils.py) to follow DRY principles.

♻️ Suggested refactor

Create a shared helper in route_utils.py:

# In src/zndraw/app/route_utils.py
def get_caller_sid() -> str | None:
    """Get socket sid of the caller from their session ID header.

    Used to exclude the caller from socket broadcasts (skip_sid).
    """
    from flask import current_app, request
    from .redis_keys import SessionKeys
    
    session_id = request.headers.get("X-Session-ID")
    if not session_id:
        return None
    r = current_app.extensions["redis"]
    return r.get(SessionKeys.session_to_sid(session_id))

Then import and use it in both route files.


149-180: Redundant auth error handling after @require_auth decorator.

The @require_auth decorator already enforces authentication and returns an error response if the user is not authenticated. The try/except AuthError block on lines 165-168 is redundant because get_current_user() will only raise AuthError if authentication fails, which is already handled by the decorator.

♻️ Suggested simplification
-    try:
-        user_name = get_current_user()
-    except AuthError as e:
-        return {"error": e.message}, e.status_code
+    user_name = get_current_user()
src/zndraw/app/geometry_routes.py (1)

383-414: Consider using SocketEvents constant for broadcast event name.

Line 408 uses a literal string "frame_selection:update" for the broadcast event. For consistency with the chat endpoints that use SocketEvents.CHAT_MESSAGE_NEW and SocketEvents.CHAT_MESSAGE_UPDATED, consider adding a corresponding constant to SocketEvents class.

Otherwise, the endpoint implementation is correct:

  • Proper validation of indices as non-negative integers
  • Redis storage using RoomKeys.frame_selection()
  • Broadcasts to room while skipping the caller to prevent UI flicker
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 377cbba and 6d47459.

📒 Files selected for processing (6)
  • src/zndraw/api_manager.py
  • src/zndraw/app/events.py
  • src/zndraw/app/geometry_routes.py
  • src/zndraw/app/screenshot_chat_routes.py
  • src/zndraw/zndraw.py
  • tests/test_chat.py
💤 Files with no reviewable changes (1)
  • src/zndraw/app/events.py
🧰 Additional context used
📓 Path-based instructions (2)
**/*.py

📄 CodeRabbit inference engine (AGENTS.md)

**/*.py: You MUST NEVER @pytest.mark.xfail or similar - all tests must pass!
All default values (e.g., camera, particles) must be defined exclusively within the Pydantic model. Do not scatter fallback logic throughout the codebase.
Do not perform null checks combined with hardcoded literals for default values. Rely entirely on the schema to populate default values during initialization.
You can not use LUA scripts with Redis!
If sensible, implement collections.abc interfaces for your classes, such as MutableMapping or MutableSequence.
Use numpy style docstrings in Python code.
Docstrings must be concise and to the point.
Use type hints wherever possible in Python. Use list[int|float] | None instead of t.Optional[t.List[int|float]]!
Imports should always be at the top of the file in Python.

Files:

  • tests/test_chat.py
  • src/zndraw/api_manager.py
  • src/zndraw/app/screenshot_chat_routes.py
  • src/zndraw/zndraw.py
  • src/zndraw/app/geometry_routes.py
**/test_*.py

📄 CodeRabbit inference engine (AGENTS.md)

When designing new tests, read the old tests first to understand the existing patterns. Use pytest.mark.parametrize to avoid code duplication. Tests should be very specific and test only one thing. Avoid complex test setups. Each test must be a function, not a method of a class!

Files:

  • tests/test_chat.py
🧬 Code graph analysis (5)
tests/test_chat.py (2)
src/zndraw/zndraw.py (1)
  • edit_message (1722-1724)
tests/conftest.py (1)
  • server (419-425)
src/zndraw/api_manager.py (2)
src/zndraw/app/geometry_routes.py (1)
  • update_frame_selection (385-414)
src/zndraw/app/screenshot_chat_routes.py (2)
  • create_chat_message (151-180)
  • edit_chat_message (187-224)
src/zndraw/app/screenshot_chat_routes.py (6)
src/zndraw/auth.py (3)
  • AuthError (20-34)
  • get_current_user (135-153)
  • require_auth (180-200)
tests/conftest.py (1)
  • server (419-425)
src/zndraw/app/chat_utils.py (3)
  • create_message (8-54)
  • get_message (57-74)
  • update_message (77-106)
src/zndraw/app/constants.py (1)
  • SocketEvents (4-25)
src/zndraw/app/redis_keys.py (4)
  • RoomKeys (242-533)
  • SessionKeys (587-652)
  • session_id (600-602)
  • session_to_sid (623-636)
src/zndraw/app/geometry_routes.py (1)
  • _get_caller_sid (20-29)
src/zndraw/zndraw.py (3)
src/zndraw/api_manager.py (3)
  • update_frame_selection (1501-1525)
  • create_chat_message (1531-1555)
  • edit_chat_message (1557-1583)
src/zndraw/app/geometry_routes.py (1)
  • update_frame_selection (385-414)
src/zndraw/app/screenshot_chat_routes.py (2)
  • create_chat_message (151-180)
  • edit_chat_message (187-224)
src/zndraw/app/geometry_routes.py (2)
src/zndraw/app/redis_keys.py (6)
  • RoomKeys (242-533)
  • SessionKeys (587-652)
  • session_id (600-602)
  • session_to_sid (623-636)
  • geometries (308-310)
  • frame_selection (392-405)
src/zndraw/app/screenshot_chat_routes.py (1)
  • _get_caller_sid (22-31)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: pytest (3.13, ubuntu-latest)
  • GitHub Check: pytest (3.12, ubuntu-latest)
  • GitHub Check: pytest (3.11, ubuntu-latest)
🔇 Additional comments (10)
tests/test_chat.py (2)

3-3: LGTM!

The pytest import is correctly placed at the top of the file with other imports.


80-84: LGTM!

The test correctly validates that unauthorized message edits raise requests.HTTPError with a 403 status code, properly reflecting the REST API migration.

src/zndraw/app/screenshot_chat_routes.py (2)

11-17: LGTM!

Imports are properly organized at the top of the file and all are utilized by the new chat message endpoints.


183-224: LGTM!

The endpoint correctly validates message ownership before allowing edits, returns appropriate HTTP status codes (404/403), and broadcasts updates to the room. The same optional simplification for the AuthError try/except block applies here as in create_chat_message.

src/zndraw/api_manager.py (3)

1501-1525: LGTM!

The method follows the established patterns in APIManager:

  • Proper numpy-style docstring with parameters, returns, and raises sections
  • Consistent use of _get_headers(), timeout, and raise_for_status()
  • Type hints match the server endpoint expectations

1531-1555: LGTM!

Consistent implementation following the established API method patterns. The docstring properly documents the return type and potential exceptions.


1557-1583: LGTM!

The method correctly uses PATCH for editing, and the docstring properly documents both 404 and 403 error cases that the tests verify.

src/zndraw/zndraw.py (3)

802-803: LGTM!

The implementation correctly calls the API first, and only updates the internal state after a successful API response. This ensures consistency between client state and server state.


1718-1720: LGTM!

Clean delegation to the API layer. The return type simplification from dict | None to dict is appropriate since failures now raise HTTPError instead of returning None.


1722-1724: LGTM!

Consistent with the log method migration. The test test_chat_message_edit_unauthorized confirms that 403 errors correctly raise HTTPError.

- Add PUT /api/rooms/{room}/frame-selection endpoint
- Add POST /api/rooms/{room}/chat/messages endpoint
- Add PATCH /api/rooms/{room}/chat/messages/{id} endpoint
- Add APIManager methods for frame selection and chat
- Update ZnDraw client to use REST instead of Socket.IO
- Remove Socket.IO event handlers (frame_selection:set, chat:message:create, chat:message:edit)
- Add skip_sid to broadcasts to avoid UI flicker for the caller
- Update test to expect HTTPError on 403 instead of response dict

This is part of the migration away from eventlet towards async.
Socket.IO is now used only for broadcasts, not for request/response patterns.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented Jan 11, 2026

Codecov Report

❌ Patch coverage is 55.84416% with 34 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.96%. Comparing base (738edfa) to head (3626656).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/zndraw/app/screenshot_chat_routes.py 28.12% 23 Missing ⚠️
src/zndraw/app/geometry_routes.py 21.42% 11 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #835      +/-   ##
==========================================
+ Coverage   79.93%   79.96%   +0.02%     
==========================================
  Files         161      162       +1     
  Lines       19745    19769      +24     
==========================================
+ Hits        15783    15808      +25     
+ Misses       3962     3961       -1     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@PythonFZ PythonFZ force-pushed the feat/rest-frame-selection-chat branch from 6d47459 to 0754fcd Compare January 11, 2026 20:32
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In @src/zndraw/app/geometry_routes.py:
- Around line 370-401: The update_frame_selection route calls request.get_json()
without a fallback, so set data = request.get_json() or {} to avoid
AttributeError when the body is missing/malformed and validate indices against
that dict; also update the socketio.emit call in update_frame_selection to pass
the caller's sid as skip_sid (retrieve the session/sid the same way other routes
do and forward it into socketio.emit) so the requester doesn't receive the
broadcast and cause UI flicker.

In @src/zndraw/app/screenshot_chat_routes.py:
- Around line 157-161: The socketio.emit call that broadcasts CHAT_MESSAGE_NEW
(SocketEvents.CHAT_MESSAGE_NEW) to the room (to=f"room:{room_id}") should
exclude the originating caller to prevent UI flicker; update the socketio.emit
invocation to pass skip_sid=<caller_sid> (e.g., skip_sid=request.sid when in a
socket context) or extract the caller's sid from the incoming HTTP
header/session and pass that value as skip_sid. Locate the emit in
screenshot_chat_routes.py (the socketio.emit(..., message, to=f"room:{room_id}")
call), obtain the caller session id appropriately for your Flask-SocketIO REST
vs socket context, and add skip_sid=caller_sid to the emit parameters.
- Around line 197-201: The broadcast currently sends the CHAT_MESSAGE_UPDATED
event to the room including the caller, causing duplicate UI updates; update the
socketio.emit call that sends SocketEvents.CHAT_MESSAGE_UPDATED with payload
updated to include skip_sid=current_socket_id (the caller's socket id) so the
emitter skips the originator and avoids flicker—use the existing room identifier
(to=f"room:{room_id}") and pass skip_sid alongside it.
🧹 Nitpick comments (1)
app/src/store.tsx (1)

412-423: Consider adding rollback on API failure for consistency.

The optimistic update pattern is good, but unlike addBookmark and deleteBookmark methods in this file (lines 774-828), setFrameSelection doesn't rollback the local state if the API call fails. This could lead to UI showing a frame selection that doesn't match the server state.

♻️ Suggested rollback pattern
 setFrameSelection: (frame_selection) => {
     const roomId = get().roomId;
     if (!roomId) return;

+    // Store old value for rollback
+    const oldFrameSelection = get().frame_selection;
+
     // Optimistic update
     set({ frame_selection: frame_selection });

     // Update via REST API
     updateFrameSelectionAPI(roomId, frame_selection || []).catch((error) => {
         console.error("Failed to update frame selection:", error);
+        // Rollback on error
+        set({ frame_selection: oldFrameSelection });
     });
 },
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6d47459 and 0754fcd.

📒 Files selected for processing (10)
  • app/src/hooks/useChat.ts
  • app/src/myapi/client.ts
  • app/src/store.tsx
  • src/zndraw/api_manager.py
  • src/zndraw/app/constants.py
  • src/zndraw/app/events.py
  • src/zndraw/app/geometry_routes.py
  • src/zndraw/app/screenshot_chat_routes.py
  • src/zndraw/zndraw.py
  • tests/test_chat.py
💤 Files with no reviewable changes (1)
  • src/zndraw/app/events.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • tests/test_chat.py
  • src/zndraw/api_manager.py
🧰 Additional context used
📓 Path-based instructions (1)
**/*.py

📄 CodeRabbit inference engine (AGENTS.md)

**/*.py: You MUST NEVER @pytest.mark.xfail or similar - all tests must pass!
All default values (e.g., camera, particles) must be defined exclusively within the Pydantic model. Do not scatter fallback logic throughout the codebase.
Do not perform null checks combined with hardcoded literals for default values. Rely entirely on the schema to populate default values during initialization.
You can not use LUA scripts with Redis!
If sensible, implement collections.abc interfaces for your classes, such as MutableMapping or MutableSequence.
Use numpy style docstrings in Python code.
Docstrings must be concise and to the point.
Use type hints wherever possible in Python. Use list[int|float] | None instead of t.Optional[t.List[int|float]]!
Imports should always be at the top of the file in Python.

Files:

  • src/zndraw/app/constants.py
  • src/zndraw/app/screenshot_chat_routes.py
  • src/zndraw/app/geometry_routes.py
  • src/zndraw/zndraw.py
🧬 Code graph analysis (3)
app/src/hooks/useChat.ts (1)
app/src/myapi/client.ts (2)
  • createChatMessage (793-801)
  • editChatMessage (808-818)
src/zndraw/app/screenshot_chat_routes.py (3)
src/zndraw/app/chat_utils.py (3)
  • create_message (8-54)
  • get_message (57-74)
  • update_message (77-106)
src/zndraw/app/constants.py (1)
  • SocketEvents (4-26)
src/zndraw/app/redis_keys.py (1)
  • RoomKeys (242-533)
app/src/store.tsx (2)
src/zndraw/zndraw.py (2)
  • frame_selection (791-793)
  • frame_selection (796-803)
src/zndraw/app/redis_keys.py (1)
  • frame_selection (392-405)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: pytest (3.11, ubuntu-latest)
  • GitHub Check: pytest (3.12, ubuntu-latest)
  • GitHub Check: pytest (3.13, ubuntu-latest)
🔇 Additional comments (8)
src/zndraw/zndraw.py (2)

796-803: LGTM! Clean migration to REST API.

The frame selection setter correctly validates input, calls the API, and updates internal state. The change from Socket.IO to REST is well-implemented.


1718-1724: LGTM! Chat methods successfully migrated to REST API.

The chat message creation and editing methods now correctly delegate to the API manager, maintaining the same interface while using REST instead of Socket.IO.

src/zndraw/app/constants.py (1)

26-26: LGTM! New socket event constant added.

The FRAME_SELECTION_UPDATE constant follows the existing naming convention and supports the new REST-based frame selection flow.

app/src/hooks/useChat.ts (2)

34-42: LGTM! Clean migration to REST API for chat messages.

The useSendMessage hook correctly calls the new createChatMessage API and returns the message. The comment explaining why query invalidation isn't needed (socket events handle cache updates) is helpful.


44-59: LGTM! Message editing successfully migrated to REST API.

The useEditMessage hook properly delegates to the new editChatMessage API client method, maintaining the same mutation pattern while using REST instead of Socket.IO.

app/src/myapi/client.ts (3)

481-489: LGTM!

The updateFrameSelection method is correctly implemented with appropriate HTTP method and typing.


788-801: LGTM!

The chat message creation interface and method are properly typed and correctly implemented.


803-818: LGTM!

The chat message editing interface and method are properly typed with the correct HTTP method (PATCH) for partial updates.

Comment on lines +370 to +401
@geometries.route("/api/rooms/<string:room_id>/frame-selection", methods=["PUT"])
@require_auth
def update_frame_selection(room_id: str):
"""Update frame selection for the room.

Body: {"indices": [0, 1, 5, 10]}

Broadcasts frame_selection:update to other clients.
"""
r = current_app.extensions["redis"]
keys = RoomKeys(room_id)
data = request.get_json()

indices = data.get("indices", [])
if not isinstance(indices, list):
return {"error": "indices must be a list"}, 400

if not all(isinstance(idx, int) and idx >= 0 for idx in indices):
return {"error": "All indices must be non-negative integers"}, 400

# Store frame selection
r.set(keys.frame_selection(), json.dumps(indices))

# Broadcast to room - update appearing confirms it was saved
socketio.emit(
SocketEvents.FRAME_SELECTION_UPDATE,
{"indices": indices},
to=f"room:{room_id}",
)

return {"success": True}, 200

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix potential AttributeError on malformed request body.

Line 381 uses request.get_json() without a default value. If the request body is missing or malformed JSON, data will be None, causing line 383's data.get("indices", []) to raise an AttributeError.

Other routes in this file (lines 67, 244, 428) use the safer pattern: request.get_json() or {}.

🐛 Proposed fix
     r = current_app.extensions["redis"]
     keys = RoomKeys(room_id)
-    data = request.get_json()
+    data = request.get_json() or {}

     indices = data.get("indices", [])

Consider adding skip_sid parameter to socketio.emit.

According to the PR objectives, skip_sid should be added to broadcasts to "avoid UI flicker for the caller." The socketio.emit call on lines 394-398 doesn't include a skip_sid parameter. This means the client that made the request will receive the broadcast event, potentially causing UI flicker if they've already optimistically updated their state.

Note: This would require passing the session ID through to this function, similar to how other routes handle it. You may need to add authentication/session handling to this endpoint.

🤖 Prompt for AI Agents
In @src/zndraw/app/geometry_routes.py around lines 370 - 401, The
update_frame_selection route calls request.get_json() without a fallback, so set
data = request.get_json() or {} to avoid AttributeError when the body is
missing/malformed and validate indices against that dict; also update the
socketio.emit call in update_frame_selection to pass the caller's sid as
skip_sid (retrieve the session/sid the same way other routes do and forward it
into socketio.emit) so the requester doesn't receive the broadcast and cause UI
flicker.

Comment on lines +157 to +161
socketio.emit(
SocketEvents.CHAT_MESSAGE_NEW,
message,
to=f"room:{room_id}",
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add skip_sid to broadcast to prevent UI flicker for the caller.

Per the PR objectives, broadcasts should skip the caller's socket ID to avoid UI flicker. The broadcast should exclude the session that initiated the request so the caller doesn't receive a duplicate update via Socket.IO.

🔧 Suggested fix

You'll need to obtain the session ID from the request context. In Flask-SocketIO, you can access it via request.sid if called from a socket context, or you may need to pass the session ID via header and look it up in the room's sessions.

+    # Get session ID to skip in broadcast (avoid UI flicker for caller)
+    # Session ID should be available from request headers or socket context
+    session_id = request.headers.get("X-Session-ID")
+    
     # Broadcast to room - message appearing confirms it was saved
     socketio.emit(
         SocketEvents.CHAT_MESSAGE_NEW,
         message,
         to=f"room:{room_id}",
+        skip_sid=session_id,
     )

Note: Verify the correct way to obtain the session/socket ID in your Flask-SocketIO setup for REST endpoints.

🤖 Prompt for AI Agents
In @src/zndraw/app/screenshot_chat_routes.py around lines 157 - 161, The
socketio.emit call that broadcasts CHAT_MESSAGE_NEW
(SocketEvents.CHAT_MESSAGE_NEW) to the room (to=f"room:{room_id}") should
exclude the originating caller to prevent UI flicker; update the socketio.emit
invocation to pass skip_sid=<caller_sid> (e.g., skip_sid=request.sid when in a
socket context) or extract the caller's sid from the incoming HTTP
header/session and pass that value as skip_sid. Locate the emit in
screenshot_chat_routes.py (the socketio.emit(..., message, to=f"room:{room_id}")
call), obtain the caller session id appropriately for your Flask-SocketIO REST
vs socket context, and add skip_sid=caller_sid to the emit parameters.

Comment on lines +197 to +201
socketio.emit(
SocketEvents.CHAT_MESSAGE_UPDATED,
updated,
to=f"room:{room_id}",
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add skip_sid to broadcast to prevent UI flicker for the caller.

Same issue as in the create endpoint—the broadcast should skip the caller's socket ID to avoid duplicate updates on the client that initiated the edit.

🔧 Suggested fix
+    # Get session ID to skip in broadcast (avoid UI flicker for caller)
+    session_id = request.headers.get("X-Session-ID")
+    
     # Broadcast to room - message appearing confirms it was saved
     socketio.emit(
         SocketEvents.CHAT_MESSAGE_UPDATED,
         updated,
         to=f"room:{room_id}",
+        skip_sid=session_id,
     )
🤖 Prompt for AI Agents
In @src/zndraw/app/screenshot_chat_routes.py around lines 197 - 201, The
broadcast currently sends the CHAT_MESSAGE_UPDATED event to the room including
the caller, causing duplicate UI updates; update the socketio.emit call that
sends SocketEvents.CHAT_MESSAGE_UPDATED with payload updated to include
skip_sid=current_socket_id (the caller's socket id) so the emitter skips the
originator and avoids flicker—use the existing room identifier
(to=f"room:{room_id}") and pass skip_sid alongside it.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/src/components/FigureWindow.tsx (1)

452-457: Fix conditional hook execution (Rules of Hooks violation) caused by early return.

The guard if (!windowInstance) return null; at lines 455-456 executes before multiple hooks (useMemo, useCallback, useEffect) are declared. On renders where windowInstance is falsy, zero hooks are called; when truthy, all hooks execute. This violates React's Rules of Hooks, which require the same number and order of hooks on every render.

Move the early return to after all hook declarations but before the rendering JSX (around where the <WindowTemplate> component is returned). This ensures all hooks are always called in the same order regardless of the windowInstance value.

🤖 Fix all issues with AI agents
In @app/src/store.tsx:
- Around line 413-429: updateFrameSelection currently does an optimistic set but
silently keeps the change on API failure; capture the previous selection before
calling set({ frame_selection }) then call updateFrameSelectionAPI(roomId,
frame_selection || []) and in the .catch examine error.response?.status (not
error.status); on any error invoke the app's user-feedback/snackbar mechanism to
show a failure message, and if error.response?.status === 403 revert by calling
set({ frame_selection: previousSelection }) to roll back the optimistic update.
Use the existing symbols updateFrameSelection, updateFrameSelectionAPI,
get().roomId and set() to locate and implement these changes.
🧹 Nitpick comments (2)
app/src/components/FigureWindow.tsx (2)

568-633: Dedupe selected frame indices before sending to REST-backed action.
With REST in the loop, avoid sending duplicates (Plotly selections can include repeated points).

Proposed change
 			// Apply frame selection - replace with newly selected frames
 			if (selectedFrames.length > 0) {
-				updateFrameSelection(selectedFrames);
+				updateFrameSelection(Array.from(new Set(selectedFrames)));
 			}

635-638: Avoid redundant “clear selection” calls (optional).
If frame_selection is already empty/null, this can generate no-op REST calls. Consider guarding (or accepting as-is if cheap).

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0754fcd and 29f4d01.

📒 Files selected for processing (2)
  • app/src/components/FigureWindow.tsx
  • app/src/store.tsx
🧰 Additional context used
🧬 Code graph analysis (1)
app/src/components/FigureWindow.tsx (1)
app/src/myapi/client.ts (1)
  • updateFrameSelection (481-489)
🪛 Biome (2.1.2)
app/src/components/FigureWindow.tsx

[error] 635-635: This hook is being called conditionally, but all hooks must be called in the exact same order in every component render.

Hooks should not be called after an early return.

For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order.
See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level

(lint/correctness/useHookAtTopLevel)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: pytest (3.11, ubuntu-latest)
  • GitHub Check: pytest (3.13, ubuntu-latest)
  • GitHub Check: pytest (3.12, ubuntu-latest)
🔇 Additional comments (3)
app/src/components/FigureWindow.tsx (1)

446-448: Store action rename usage looks consistent (updateFrameSelection).
Good alignment with the new optimistic+REST-backed store action.

app/src/store.tsx (2)

4-16: Import aliasing is clear; verify thrown error shape from updateFrameSelectionAPI.
Store-side handling likely depends on whether the client throws an HTTPError (as tests now expect for 403).


147-149: Public store API split (setFrameSelection vs updateFrameSelection) is correctly implemented.

Socket-driven updates in useSocketManager.ts properly use setFrameSelection (at lines 159 and 280), while user actions in FigureWindow.tsx properly use updateFrameSelection (at lines 624 and 636). The semantic split is enforced across call sites.

Comment thread app/src/store.tsx
Comment on lines +413 to +429
// For socket broadcasts - local state only, no API call
setFrameSelection: (frame_selection) =>
set({ frame_selection: frame_selection }),

// For user-initiated changes - local state + API call
updateFrameSelection: (frame_selection) => {
const roomId = get().roomId;
if (!roomId) return;

// Optimistic update
set({ frame_selection: frame_selection });

// Update via REST API
updateFrameSelectionAPI(roomId, frame_selection || []).catch((error) => {
console.error("Failed to update frame selection:", error);
});
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Get context around the mentioned lines in store.tsx
fd -t f "store.tsx" --path "*/src/*" | head -5

Repository: zincware/ZnDraw

Length of output: 292


🏁 Script executed:

#!/bin/bash
# Search for the store.tsx file
find . -name "store.tsx" -type f

Repository: zincware/ZnDraw

Length of output: 78


🏁 Script executed:

#!/bin/bash
# Examine the store.tsx file around lines 413-429
if [ -f "app/src/store.tsx" ]; then
  wc -l app/src/store.tsx
fi

Repository: zincware/ZnDraw

Length of output: 81


🏁 Script executed:

#!/bin/bash
# Look for updateFrameSelectionAPI definition
rg "updateFrameSelectionAPI" --type ts --type tsx -B 3 -A 10

Repository: zincware/ZnDraw

Length of output: 86


🏁 Script executed:

#!/bin/bash
# Check if showSnackbar exists in the store
rg "showSnackbar" --type ts --type tsx -B 2 -A 2

Repository: zincware/ZnDraw

Length of output: 86


🏁 Script executed:

#!/bin/bash
# Read lines 413-429 with context from store.tsx
sed -n '400,450p' app/src/store.tsx

Repository: zincware/ZnDraw

Length of output: 1727


🏁 Script executed:

#!/bin/bash
# Search for updateFrameSelectionAPI
rg "updateFrameSelectionAPI" -t js -B 3 -A 15

Repository: zincware/ZnDraw

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Search for showSnackbar
rg "showSnackbar" -t js -B 2 -A 5

Repository: zincware/ZnDraw

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Look for other API error handling in store
rg "catch.*error" app/src/store.tsx -B 2 -A 5

Repository: zincware/ZnDraw

Length of output: 3226


🏁 Script executed:

#!/bin/bash
# Check if error.status is used anywhere in error handling
rg "error.*status|error\.status" -t js -B 2 -A 2

Repository: zincware/ZnDraw

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Look at updateFrameSelectionAPI and similar API functions
rg "API\(" -t js -B 2 -A 8 | head -80

Repository: zincware/ZnDraw

Length of output: 41


🏁 Script executed:

#!/antml:thinking
Need to search for the API implementations to see how errors are structured
</thinking>
bash
# Find API files
find . -name "*api*" -type f | grep -E "\.(ts|tsx|js)$"

Repository: zincware/ZnDraw

Length of output: 206


🏁 Script executed:

#!/bin/bash
# Find API files
find . -name "*api*" -type f \( -name "*.ts" -o -name "*.tsx" -o -name "*.js" \)

Repository: zincware/ZnDraw

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Search for updateFrameSelectionAPI definition
rg "updateFrameSelectionAPI\s*=" -A 20

Repository: zincware/ZnDraw

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Look for error status handling in API calls
rg "status\s*===" -A 3 -B 3

Repository: zincware/ZnDraw

Length of output: 4028


🏁 Script executed:

#!/bin/bash
# Check the store.tsx file for showSnackbar method definition
rg "showSnackbar\s*:" app/src/store.tsx -B 2 -A 10

Repository: zincware/ZnDraw

Length of output: 1056


🏁 Script executed:

#!/bin/bash
# Find API implementation files
find . -path ./node_modules -prune -o -name "*.ts" -o -name "*.tsx" | grep -i api | head -20

Repository: zincware/ZnDraw

Length of output: 84


🏁 Script executed:

#!/bin/bash
# Search for where updateFrameSelectionAPI is imported from
rg "import.*updateFrameSelectionAPI|from.*updateFrameSelectionAPI" -A 2 -B 2

Repository: zincware/ZnDraw

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Look for API client implementation
find . -path ./node_modules -prune -o -type f -name "*.ts" -print | xargs grep -l "updateFrameSelectionAPI" 2>/dev/null

Repository: zincware/ZnDraw

Length of output: 43


🏁 Script executed:

#!/bin/bash
# Examine the client.ts file for updateFrameSelectionAPI
rg "updateFrameSelectionAPI" app/src/myapi/client.ts -B 5 -A 15

Repository: zincware/ZnDraw

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Look at the imports and error handling in client.ts
head -100 app/src/myapi/client.ts

Repository: zincware/ZnDraw

Length of output: 3100


🏁 Script executed:

#!/bin/bash
# Check how errors are structured from API calls
grep -A 10 "catch.*error" app/src/myapi/client.ts | head -40

Repository: zincware/ZnDraw

Length of output: 828


🏁 Script executed:

#!/bin/bash
# Search for updateFrameSelectionAPI in the entire codebase
rg "updateFrameSelectionAPI" --type js -B 2 -A 20

Repository: zincware/ZnDraw

Length of output: 41


🏁 Script executed:

#!/an/bash
# Search in all JS/TS files for the function definition
find . -name "*.ts" -o -name "*.tsx" | xargs grep -l "updateFrameSelectionAPI" 2>/dev/null

Repository: zincware/ZnDraw

Length of output: 78


🏁 Script executed:

#!/bin/bash
# Look at more of client.ts to find updateFrameSelectionAPI
wc -l app/src/myapi/client.ts && echo "---" && tail -400 app/src/myapi/client.ts | head -200

Repository: zincware/ZnDraw

Length of output: 5199


🏁 Script executed:

#!/bin/bash
# Search for the exact line where updateFrameSelectionAPI is exported/defined
rg "export.*updateFrameSelectionAPI|updateFrameSelectionAPI\s*=" app/src/myapi/client.ts -B 3 -A 15

Repository: zincware/ZnDraw

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Get the middle section of client.ts that likely contains updateFrameSelectionAPI
sed -n '700,900p' app/src/myapi/client.ts

Repository: zincware/ZnDraw

Length of output: 4756


🏁 Script executed:

#!/bin/bash
# Look for selection-related API functions in client.ts
rg "Selection|selection" app/src/myapi/client.ts -B 2 -A 10

Repository: zincware/ZnDraw

Length of output: 3628


🏁 Script executed:

#!/bin/bash
# Check what is imported at the top of store.tsx
head -150 app/src/store.tsx | grep -E "import|from"

Repository: zincware/ZnDraw

Length of output: 405


🏁 Script executed:

#!/bin/bash
# Search for where updateFrameSelectionAPI comes from in store.tsx
rg "updateFrameSelectionAPI" app/src/store.tsx -B 30 | head -80

Repository: zincware/ZnDraw

Length of output: 1284


🏁 Script executed:

#!/bin/bash
# Look for test files related to frame selection
find . -name "*.test.*" -o -name "*.spec.*" | xargs grep -l "frameSelection\|frame.selection" 2>/dev/null | head -5

Repository: zincware/ZnDraw

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Check if there are any 403 status codes mentioned in tests
rg "403" --type js -B 3 -A 3 | grep -A 5 -B 5 "frame\|selection"

Repository: zincware/ZnDraw

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Look at other API error handling patterns in store.tsx to see if rollbacks are common
sed -n '200,600p' app/src/store.tsx | rg "catch.*error" -A 10

Repository: zincware/ZnDraw

Length of output: 1713


🏁 Script executed:

#!/bin/bash
# Verify the axios error structure by looking at how errors are checked in the codebase
rg "error\?.response\?.status|error\.response\.status" --type js -B 3 -A 3 | head -60

Repository: zincware/ZnDraw

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Look at the setBookmarkAPI and deleteBookmarkAPI to see rollback pattern
rg "setBookmarkAPI|deleteBookmarkAPI" app/src/store.tsx -A 25 | head -100

Repository: zincware/ZnDraw

Length of output: 2363


🏁 Script executed:

#!/bin/bash
# Check if similar methods have error handling patterns we should follow
sed -n '600,900p' app/src/store.tsx | grep -A 15 "catch.*error"

Repository: zincware/ZnDraw

Length of output: 2466


🏁 Script executed:

#!/bin/bash
# Check if error.status (direct property) vs error.response.status is used in axios error handling
rg "error\.\w+\s*===" app/src/ -B 2 -A 2 | head -40

Repository: zincware/ZnDraw

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Verify axios error structure in existing code
rg "catch.*error.*\{" app/src/hooks/ -A 15 | head -60

Repository: zincware/ZnDraw

Length of output: 3485


🏁 Script executed:

#!/bin/bash
# Look at a complete bookmark method with rollback to compare
sed -n '330,380p' app/src/store.tsx

Repository: zincware/ZnDraw

Length of output: 1861


🏁 Script executed:

#!/bin/bash
# Double-check the axios error structure used throughout the codebase
rg "error\.response\?.status" app/src/ -B 2 -A 2 | head -40

Repository: zincware/ZnDraw

Length of output: 1163


🏁 Script executed:

#!/bin/bash
# Look for any usage of error.status (without response)
rg "error\?.status\s*===" app/src/ -B 2 -A 2

Repository: zincware/ZnDraw

Length of output: 41


🏁 Script executed:

#!/bin/bash
# Verify the complete updateFrameSelection implementation one more time
sed -n '415,430p' app/src/store.tsx

Repository: zincware/ZnDraw

Length of output: 543


Add error feedback and rollback handling for frame selection updates.

The current implementation silently fails when the API rejects (e.g., 403), keeping optimistic state even though the server rejected the change. Additionally, there's no user feedback on failure.

Consider: (1) always apply the local update to keep UI responsive, (2) show a snackbar on failure, (3) optionally rollback on permission errors (403). Note: use error.response?.status (not error.status) to check axios error codes, as shown elsewhere in the codebase.

Proposed change
 	// For user-initiated changes - local state + API call
 	updateFrameSelection: (frame_selection) => {
-		const roomId = get().roomId;
-		if (!roomId) return;
-
-		// Optimistic update
-		set({ frame_selection: frame_selection });
+		const prev = get().frame_selection;
+		const roomId = get().roomId;
+
+		// Always update locally (keeps UI responsive even in edge init states)
+		set({ frame_selection });
+
+		// If we can't persist yet, stop here
+		if (!roomId) return;
 
 		// Update via REST API
 		updateFrameSelectionAPI(roomId, frame_selection || []).catch((error) => {
 			console.error("Failed to update frame selection:", error);
+			// Check error.response?.status (axios error structure used throughout codebase)
+			if ((error as any)?.response?.status === 403) {
+				set({ frame_selection: prev });
+			}
+			get().showSnackbar("Failed to update frame selection", "error");
 		});
 	},
🤖 Prompt for AI Agents
In @app/src/store.tsx around lines 413 - 429, updateFrameSelection currently
does an optimistic set but silently keeps the change on API failure; capture the
previous selection before calling set({ frame_selection }) then call
updateFrameSelectionAPI(roomId, frame_selection || []) and in the .catch examine
error.response?.status (not error.status); on any error invoke the app's
user-feedback/snackbar mechanism to show a failure message, and if
error.response?.status === 403 revert by calling set({ frame_selection:
previousSelection }) to roll back the optimistic update. Use the existing
symbols updateFrameSelection, updateFrameSelectionAPI, get().roomId and set() to
locate and implement these changes.

PythonFZ and others added 2 commits January 12, 2026 08:54
- Add null safety for request.get_json() in geometry_routes.py
- Fix Rules of Hooks violation in FigureWindow.tsx (move early return after hooks)
- Dedupe selected frame indices before sending to REST API
- Guard redundant clear selection calls when already empty

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@PythonFZ PythonFZ merged commit 1971266 into main Jan 13, 2026
6 checks passed
@PythonFZ PythonFZ deleted the feat/rest-frame-selection-chat branch January 13, 2026 11:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants