# ISS-0030: 前端刷新会导致 chat message 显示丢失

## Background

User `lingtao.li@pingcap.com` reported in GitHub issue #194 that refreshing the browser causes chat messages to disappear. Workspace: `ws-1776337653260-0e3440ed7`.

## Root Cause Analysis

Messages are persisted to the backend via `appendMessagesToAPI()` (POST `/api/workspace/{id}/messages`). The **interactive chat path** (`chat.py` SSE stream) does NOT persist messages server-side — it relies entirely on the frontend to call `appendMessagesToAPI` after events occur.

The persistence timeline for an interactive chat message is:

```
User sends message
  → Frontend calls appendMessagesToAPI(userMessage)         ← PERSISTED immediately
  → Frontend opens EventSource to /api/chat/stream
  → SSE events stream in, updating React state only        ← NOT PERSISTED
  → On "complete" event:
      Frontend calls appendMessagesToAPI(assistantMessage)  ← PERSISTED only now
```

**Problem**: If the user refreshes during streaming (between user message send and `complete` event), the assistant message exists only in React state and is lost. On reload, `loadMessagesFromAPI` fetches from backend, which only has the user message.

Additionally, there's a 2-second delay after `complete` where `isStreamingRef.current` stays true (line 744), during which the SSE-based message update (`applyUpdate`) is blocked. If another client persisted the message in that window and the user refreshes immediately after, there could be a brief inconsistency.

### Contrast with auto_chat path

`backend/investibook/core/auto_chat.py` persists messages independently via `_db_append_messages_v2` at multiple points (lines 348, 376, 405, 428). So auto-diagnosis messages survive refreshes. Only **interactive chat** messages are affected.

## Observed Symptoms

1. User sends a message, agent starts responding
2. User refreshes (or browser loses connection)
3. On reload, the agent's response is gone — only the user message (or nothing) shows
4. The response is permanently lost (no backend record)

## Fix Plan

### Option A: Backend-side persistence for streaming messages (recommended)

Modify `backend/investibook/api/chat.py` to persist the assistant message to the DB as it streams:

1. **On stream start**: Insert a placeholder assistant message with `status: "streaming"` and empty content
2. **Periodically during stream** (e.g., every 5 seconds or every N tokens): Update the message content in-place via `_db_append_messages_v2` (which is idempotent by message ID)
3. **On stream complete**: Final update with full content + parts + timingMeta

This way, if the user refreshes mid-stream, `loadMessagesFromAPI` will return the partially-streamed message. The frontend can show it with a "streaming was interrupted" indicator.

**Files to modify:**
- `backend/investibook/api/chat.py` — add periodic persistence in the SSE generator
- `backend/investibook/db/queries.py` — ensure `_db_append_messages_v2` handles upsert correctly (it already does by message ID)
- `frontend/components/ChatPanel/index.tsx` — handle `status: "streaming"` messages on load (show with indicator)

### Option B: Frontend-side progressive persistence (simpler, less robust)

Modify the frontend to call `appendMessagesToAPI` periodically during streaming, not just on completion:

1. In `eventSource.onmessage`, after every N events (or every 3 seconds), call `appendMessagesToAPI` with current accumulated content
2. Since `appendMessagesToAPI` uses POST and the backend is idempotent by message ID, repeated calls are safe

**Files to modify:**
- `frontend/components/ChatPanel/index.tsx` — add a periodic save timer during streaming

**Tradeoff**: More API calls from frontend, but simpler backend change. Still has a window of up to 3 seconds where data can be lost.

### Option C: Hybrid — backend persists on completion, frontend persists incrementally

Combine: frontend persists every 5 seconds during streaming (Option B), and backend persists the final message on `complete` SSE event (new: add a backend webhook from the agent layer).

## Recommended Approach

**Option A** is the most robust — it guarantees no message loss regardless of frontend behavior. The backend already has the infrastructure (`_db_append_messages_v2` with idempotent upsert by message ID).

## Implementation Steps (Option A)

### Step 1: Add streaming persistence to chat.py

In the SSE generator function in `chat.py`, after sending each chunk to the client, accumulate content. Every 5 seconds (or on `complete`), persist:

```python
# Pseudocode in the SSE generator
accumulated_content = ""
last_persist_time = time.time()
assistant_msg_id = str(uuid.uuid4())

async for event in agent_stream:
    accumulated_content += event.text_delta or ""
    yield f"data: {json.dumps(event)}\n\n"

    # Periodic persist
    if time.time() - last_persist_time > 5:
        _db_append_messages_v2(workspace_id, [{
            "id": assistant_msg_id,
            "role": "assistant",
            "content": accumulated_content,
            "parts": current_parts,
            "timestamp": now_iso,
        }])
        last_persist_time = time.time()

# Final persist on complete
_db_append_messages_v2(workspace_id, [{
    "id": assistant_msg_id,
    "role": "assistant",
    "content": final_content,
    "parts": final_parts,
    "timestamp": now_iso,
}])
```

### Step 2: Coordinate with frontend

The frontend also calls `appendMessagesToAPI` on completion. Since both use the same message ID and the DB is idempotent, this is safe — the last writer wins with the most complete content.

Remove the frontend-only persistence? No — keep it as a fallback. The frontend may have richer `parts` data (timingMeta, etc.) that the backend stream doesn't track.

### Step 3: Handle interrupted streams on reload

When `loadMessagesFromAPI` returns a message that was mid-stream (backend persisted partial content), the frontend should:
- Display it normally (partial content is better than nothing)
- Optionally show a subtle indicator: "Response was interrupted"

### Step 4: Notify SSE subscribers on persist

The backend already broadcasts `{type: "updated"}` via the messages/stream SSE when messages are written. The periodic persist in Step 1 will trigger these broadcasts, so other clients (or the same client after reconnect) will pick up the partial message.

## Verification

1. Start a diagnosis, wait for streaming to begin
2. Refresh the browser mid-stream
3. Verify the partial assistant response appears after reload
4. Wait for the agent to finish (if session is still alive), verify the full response eventually appears
5. Test with multiple browser tabs open simultaneously
