pycodex-ws 0.4.2__tar.gz → 0.4.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/PKG-INFO +2 -2
  2. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/pycodex_router/client.py +1 -0
  3. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/pycodex_router/index.html +20 -6
  4. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/pycodex_router/server.py +2 -0
  5. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/pyproject.toml +2 -2
  6. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/app.py +35 -0
  7. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/workspace.html +69 -4
  8. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/workspaces.html +87 -20
  9. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/workspaces.py +16 -0
  10. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/.gitignore +0 -0
  11. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/LICENSE +0 -0
  12. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/README.md +0 -0
  13. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/pycodex_router/__init__.py +0 -0
  14. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/pycodex_router/__main__.py +0 -0
  15. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/pycodex_router/browser.py +0 -0
  16. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/pycodex_router/protocol.py +0 -0
  17. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/pycodex_ws/__init__.py +0 -0
  18. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/pycodex_ws/feishu_card.py +0 -0
  19. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/pycodex_ws/feishu_link.py +0 -0
  20. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/pycodex_ws/feishu_oauth.py +0 -0
  21. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/__init__.py +0 -0
  22. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/__main__.py +0 -0
  23. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/content_views.LICENSE.txt +0 -0
  24. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/content_views.css +0 -0
  25. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/content_views.mjs +0 -0
  26. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/editor.css +0 -0
  27. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/editor.mjs +0 -0
  28. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/frontend/.gitignore +0 -0
  29. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/frontend/build.mjs +0 -0
  30. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/frontend/composer.mjs +0 -0
  31. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/frontend/content_views.css +0 -0
  32. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/frontend/content_views.mjs +0 -0
  33. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/frontend/editor.css +0 -0
  34. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/frontend/editor.mjs +0 -0
  35. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/frontend/editor_context.mjs +0 -0
  36. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/frontend/package-lock.json +0 -0
  37. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/frontend/package.json +0 -0
  38. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/frontend/view_labels.mjs +0 -0
  39. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/owned_process.py +0 -0
  40. {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/workspace_views.py +0 -0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pycodex-ws
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Summary: Browser workspace and optional Feishu integration for python-codex
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.7
7
7
  Requires-Dist: pillow>=9.0.0
8
- Requires-Dist: python-codex==0.4.2
8
+ Requires-Dist: python-codex==0.4.3
9
9
  Requires-Dist: websockets<16,>=11
10
10
  Description-Content-Type: text/markdown
11
11
 
@@ -235,6 +235,7 @@ def attach_router(app, registry, target, port):
235
235
  "enable_board": workspace["enable_board"],
236
236
  "session_count": len(workspace["sessions"]),
237
237
  "running": any(session["running"] for session in workspace["sessions"]),
238
+ "unread": any(session["unread"] for session in workspace["sessions"]),
238
239
  }
239
240
  for workspace in registry.list_workspaces()
240
241
  ]
@@ -73,21 +73,31 @@
73
73
  .workspace-group-heading { display: flex; align-items: center; gap: 10px; padding: 12px 14px; }
74
74
  .workspace-group-name { flex: 1; min-width: 0; font-size: 12px; font-weight: 600; line-height: 1.6; overflow-wrap: anywhere; }
75
75
  .workspace-group-heading .count { flex: 0 0 auto; }
76
- .list-head, .workspace-row { display: grid; grid-template-columns: minmax(94px, .85fr) minmax(120px, 1.85fr) 60px 44px; gap: 12px; align-items: center; padding: 10px 14px; }
76
+ .list-head, .workspace-row { display: grid; grid-template-columns: minmax(94px, .85fr) minmax(120px, 1.85fr) 76px 44px; gap: 12px; align-items: center; padding: 10px 14px; }
77
77
  .list-head { position: sticky; top: 0; z-index: 1; background: var(--soft); color: var(--muted); font-size: 10px; font-weight: 550; letter-spacing: .3px; }
78
- .workspace-row { min-height: 62px; border-top: 1px solid var(--line); }
78
+ .workspace-row { position: relative; min-height: 62px; border-top: 1px solid var(--line); }
79
79
  .workspace-row > * { min-width: 0; }
80
80
  .workspace-row:hover { background: var(--soft); }
81
81
  .workspace-row:last-child { border-radius: 0 0 9px 9px; }
82
- .activity-badge { justify-self: start; display: inline-flex; align-items: center; gap: 5px; padding: 3px 6px; border-radius: 4px; color: var(--muted); background: var(--soft); font-size: 10px; font-weight: 500; }
82
+ .activity-badge { display: inline-flex; align-items: center; gap: 5px; padding: 3px 6px; border-radius: 4px; color: var(--muted); background: var(--soft); font-size: 10px; font-weight: 500; }
83
83
  .activity-badge[data-state="working"] { color: var(--green); background: var(--green-soft); }
84
- .activity-badge[data-state="working"]::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
84
+ .activity-badge[data-state="working"]::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; animation: spinner-pulse 1.2s cubic-bezier(0.4, 0, 0.2, 1) infinite; }
85
+ @keyframes spinner-pulse {
86
+ 0%, 100% { opacity: .45; transform: scale(.82); }
87
+ 50% { opacity: 1; transform: scale(1.08); }
88
+ }
89
+ @media (prefers-reduced-motion: reduce) {
90
+ .activity-badge[data-state="working"]::before { animation: none; opacity: .8; transform: none; }
91
+ }
92
+ .workspace-status { display: inline-flex; align-items: center; gap: 6px; justify-self: start; }
93
+ .unread-dot { flex: 0 0 6px; width: 6px; height: 6px; border-radius: 50%; background: var(--danger); }
85
94
  .workspace-title { font-size: 12px; line-height: 1.6; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
86
95
  .workspace-path, .state-path { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; line-height: 1.8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
87
96
  .state-path { color: var(--muted); }
88
97
  .session-count { color: var(--muted); font-size: 11px; line-height: 1.7; }
89
98
  .open-link, .manage-link { display: inline-flex; align-items: center; justify-content: flex-end; gap: 4px; min-height: 28px; color: var(--accent); font-size: 11px; font-weight: 550; white-space: nowrap; }
90
99
  .open-link svg, .manage-link svg { width: 13px; height: 13px; }
100
+ .open-link::after { content: ""; position: absolute; inset: 0; }
91
101
  .open-link:hover, .manage-link:hover { text-decoration: underline; text-underline-offset: 4px; }
92
102
  .device { min-width: 0; padding: 12px 14px; }
93
103
  .device + .device { border-top: 1px solid var(--line); }
@@ -117,7 +127,7 @@
117
127
  .list-head { display: none; }
118
128
  .workspace-row { grid-template-columns: minmax(0, 1fr) auto auto; gap: 6px 10px; padding: 10px 12px; }
119
129
  .workspace-paths { grid-column: 1 / -1; grid-row: 2; }
120
- .activity-badge { grid-column: 2; grid-row: 1; }
130
+ .workspace-status { grid-column: 2; grid-row: 1; }
121
131
  .open-link { grid-column: 3; grid-row: 1; min-height: 36px; }
122
132
  }
123
133
  </style>
@@ -191,7 +201,10 @@
191
201
  <p class="workspace-path"></p>
192
202
  <p class="state-path"></p>
193
203
  </div>
194
- <span class="activity-badge"></span>
204
+ <span class="workspace-status">
205
+ <span class="unread-dot" role="img" aria-label="Unread reply" title="Unread reply"></span>
206
+ <span class="activity-badge"></span>
207
+ </span>
195
208
  <a class="open-link">Open <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14m-6-6 6 6-6 6"/></svg></a>
196
209
  </article>
197
210
  </template>
@@ -346,6 +359,7 @@
346
359
  const title = card.querySelector(".workspace-title");
347
360
  title.textContent = workspace.id;
348
361
  title.title = workspace.id;
362
+ card.querySelector(".unread-dot").hidden = !workspace.unread;
349
363
  const path = card.querySelector(".workspace-path");
350
364
  path.textContent = workspace.work_dir;
351
365
  path.title = workspace.work_dir;
@@ -114,6 +114,7 @@ def validate_inventory(workspaces):
114
114
  "enable_board",
115
115
  "session_count",
116
116
  "running",
117
+ "unread",
117
118
  }
118
119
  seen = set()
119
120
  for workspace in workspaces:
@@ -136,6 +137,7 @@ def validate_inventory(workspaces):
136
137
  if (
137
138
  type(workspace["enable_board"]) is not bool
138
139
  or type(workspace["running"]) is not bool
140
+ or type(workspace["unread"]) is not bool
139
141
  or type(workspace["session_count"]) is not int
140
142
  or not 0 <= workspace["session_count"] <= 1000000
141
143
  ):
@@ -4,12 +4,12 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pycodex-ws"
7
- version = "0.4.2"
7
+ version = "0.4.3"
8
8
  description = "Browser workspace and optional Feishu integration for python-codex"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.7"
11
11
  dependencies = [
12
- "python-codex==0.4.2",
12
+ "python-codex==0.4.3",
13
13
  "pillow>=9.0.0",
14
14
  "websockets>=11,<16",
15
15
  ]
@@ -198,6 +198,7 @@ class WebSessionView:
198
198
  self._turns_by_submission_id: "typing.Dict[str, typing.Dict[str, object]]" = {}
199
199
  self._turns_by_turn_id: "typing.Dict[str, typing.Dict[str, object]]" = {}
200
200
  self._title = ""
201
+ self._last_final_id = ""
201
202
  self._model = "pycodex"
202
203
  self._rollout_path = ""
203
204
  self._recorded_rollout_path = ""
@@ -276,6 +277,7 @@ class WebSessionView:
276
277
  self._turns_by_submission_id = {}
277
278
  self._turns_by_turn_id = {}
278
279
  self._events = []
280
+ self._last_final_id = ""
279
281
  for entry in history:
280
282
  if entry is None:
281
283
  self._add_compaction_marker()
@@ -318,6 +320,7 @@ class WebSessionView:
318
320
  with self._lock:
319
321
  return {
320
322
  "running": bool(self._spinner_status),
323
+ "last_final_id": self._last_final_id,
321
324
  "status": self._spinner_status,
322
325
  "status_kind": "spinner" if self._spinner_status else "idle",
323
326
  "spinner": self._spinner_status,
@@ -341,6 +344,7 @@ class WebSessionView:
341
344
  return {
342
345
  "model": self._model,
343
346
  "running": bool(self._spinner_status),
347
+ "last_final_id": self._last_final_id,
344
348
  "spinner": self._spinner_status,
345
349
  "title": self._title,
346
350
  "turn_count": len(self._turns),
@@ -462,6 +466,8 @@ class WebSessionView:
462
466
  response = event.visualize()
463
467
  if response:
464
468
  turn["response"] = response
469
+ turn["final_id"] = event.turn_id
470
+ self._last_final_id = event.turn_id
465
471
  elif turn.get("thinking"):
466
472
  turn["response"] = str(turn.get("thinking") or "")
467
473
  turn["thinking"] = ""
@@ -979,6 +985,13 @@ def create_multi_workspace_app(
979
985
  entry.manager, request.path_params["session_id"]
980
986
  )
981
987
 
988
+ async def workspace_read_session(request: Request) -> JSONResponse:
989
+ payload = await _json_object_body(request)
990
+ entry = _workspace_entry_or_404(registry, request.path_params["workspace_id"])
991
+ return _read_session_response(
992
+ entry.manager, request.path_params["session_id"], payload
993
+ )
994
+
982
995
  async def workspace_session(request: Request) -> JSONResponse:
983
996
  entry = _workspace_entry_or_404(registry, request.path_params["workspace_id"])
984
997
  return _session_response(entry.manager, request.query_params.get("session_id"))
@@ -1054,6 +1067,10 @@ def create_multi_workspace_app(
1054
1067
  "/w/{workspace_id}/api/sessions/{session_id}",
1055
1068
  workspace_delete_session, methods=["DELETE"],
1056
1069
  ),
1070
+ Route(
1071
+ "/w/{workspace_id}/api/sessions/{session_id}/read",
1072
+ workspace_read_session, methods=["POST"],
1073
+ ),
1057
1074
  Route("/w/{workspace_id}/api/session", workspace_session, methods=["GET"]),
1058
1075
  Route(
1059
1076
  "/w/{workspace_id}/api/session/message",
@@ -1348,6 +1365,10 @@ def _install_workspace_routes(
1348
1365
  async def delete_session(request: Request) -> JSONResponse:
1349
1366
  return await _delete_session_response(manager, request.path_params["session_id"])
1350
1367
 
1368
+ async def read_session(request: Request) -> JSONResponse:
1369
+ payload = await _json_object_body(request)
1370
+ return _read_session_response(manager, request.path_params["session_id"], payload)
1371
+
1351
1372
  async def session(request: Request) -> JSONResponse:
1352
1373
  return _session_response(manager, request.query_params.get("session_id"))
1353
1374
 
@@ -1385,6 +1406,7 @@ def _install_workspace_routes(
1385
1406
  Route("/api/sessions", sessions, methods=["GET"]),
1386
1407
  Route("/api/sessions", new_session, methods=["POST"]),
1387
1408
  Route("/api/sessions/{session_id}", delete_session, methods=["DELETE"]),
1409
+ Route("/api/sessions/{session_id}/read", read_session, methods=["POST"]),
1388
1410
  Route("/api/session", session, methods=["GET"]),
1389
1411
  Route("/api/session/message", message, methods=["POST"]),
1390
1412
  WebSocketRoute("/ws/session", websocket_session),
@@ -1534,6 +1556,18 @@ def _sessions_response(manager: "WorkspaceSessionManager") -> JSONResponse:
1534
1556
  return JSONResponse({"sessions": manager.list_sessions()})
1535
1557
 
1536
1558
 
1559
+ def _read_session_response(
1560
+ manager: "WorkspaceSessionManager", session_id: str, payload: "typing.Dict[str, object]"
1561
+ ) -> JSONResponse:
1562
+ try:
1563
+ manager.mark_read(session_id, payload.get("final_id"))
1564
+ except KeyError:
1565
+ raise HTTPException(status_code=404, detail="session not found")
1566
+ except ValueError as exc:
1567
+ raise HTTPException(status_code=400, detail=str(exc))
1568
+ return _sessions_response(manager)
1569
+
1570
+
1537
1571
  async def _new_session_response(manager: "WorkspaceSessionManager") -> JSONResponse:
1538
1572
  session_id = await manager.create_session()
1539
1573
  return JSONResponse(
@@ -1670,6 +1704,7 @@ def _public_turn(turn: "typing.Dict[str, object]") -> "typing.Dict[str, object]"
1670
1704
  {
1671
1705
  "submission_id": turn.get("submission_id", ""),
1672
1706
  "turn_id": turn.get("turn_id", ""),
1707
+ "final_id": turn.get("final_id", ""),
1673
1708
  "prompt": turn.get("prompt", ""),
1674
1709
  "response": turn.get("response", ""),
1675
1710
  "thinking": turn.get("thinking", ""),
@@ -307,6 +307,13 @@
307
307
  .tab-label[hidden] {
308
308
  display: none;
309
309
  }
310
+ .unread-dot {
311
+ flex: 0 0 6px;
312
+ width: 6px;
313
+ height: 6px;
314
+ border-radius: 50%;
315
+ background: var(--danger);
316
+ }
310
317
  .tab-label {
311
318
  min-width: 0;
312
319
  overflow: hidden;
@@ -1774,6 +1781,7 @@
1774
1781
  notifyWhenDone: false,
1775
1782
  wasRunning: false,
1776
1783
  inputRequest: null,
1784
+ readingFinalId: "",
1777
1785
  });
1778
1786
  }
1779
1787
  return sessionState.get(key);
@@ -1828,6 +1836,13 @@
1828
1836
  tab.setAttribute("role", "tab");
1829
1837
  tab.addEventListener("click", () => switchSession(sessionId));
1830
1838
 
1839
+ const unread = document.createElement("span");
1840
+ unread.className = "unread-dot";
1841
+ unread.title = "Unread reply";
1842
+ unread.setAttribute("role", "img");
1843
+ unread.setAttribute("aria-label", "Unread reply");
1844
+ tab.appendChild(unread);
1845
+
1831
1846
  const status = document.createElement("span");
1832
1847
  status.className = "tab-status";
1833
1848
  status.setAttribute("aria-hidden", "true");
@@ -1866,6 +1881,7 @@
1866
1881
  tab.setAttribute("aria-selected", sessionId === activeSessionId ? "true" : "false");
1867
1882
  tab.setAttribute("aria-busy", String(Boolean(session.running)));
1868
1883
  tab.querySelector(".tab-status").hidden = !session.running;
1884
+ tab.querySelector(".unread-dot").hidden = !session.unread;
1869
1885
  const label = tab.querySelector(".tab-label");
1870
1886
  label.textContent = session.title || `Session ${index + 1}`;
1871
1887
  label.title = sessionId === activeSessionId ? "Click to rename session" : tab.title;
@@ -2008,6 +2024,7 @@
2008
2024
  turns.map((item) => [
2009
2025
  item.submission_id || "",
2010
2026
  item.turn_id || "",
2027
+ item.final_id || "",
2011
2028
  item.prompt || "",
2012
2029
  item.response || "",
2013
2030
  item.thinking || "",
@@ -2075,14 +2092,15 @@
2075
2092
  return;
2076
2093
  }
2077
2094
  const signature = `${payload.mtime_ns || ""}:${payload.size || ""}`;
2095
+ // Automatic Canvas refreshes must not add browser Back history entries.
2078
2096
  if (lastBoardSignature === null) {
2079
2097
  lastBoardSignature = signature;
2080
- boardFrame.src = `board?t=${Date.now()}`;
2098
+ boardFrame.contentWindow.location.replace(relativeUrl(`board?t=${Date.now()}`));
2081
2099
  return;
2082
2100
  }
2083
2101
  if (signature !== lastBoardSignature) {
2084
2102
  lastBoardSignature = signature;
2085
- boardFrame.src = `board?t=${Date.now()}`;
2103
+ boardFrame.contentWindow.location.replace(relativeUrl(`board?t=${Date.now()}`));
2086
2104
  }
2087
2105
  } catch (_error) {
2088
2106
  return;
@@ -2116,6 +2134,7 @@
2116
2134
  const state = stateForSession(activeSessionId);
2117
2135
  state.inputRequest = snapshot.input_request || null;
2118
2136
  renderQueueHint(snapshot.queued_inputs);
2137
+ requestAnimationFrame(markVisibleFinalRead);
2119
2138
  const signature = messageSignature(snapshot);
2120
2139
  if (signature === state.lastRenderedSignature) {
2121
2140
  setSpinner(snapshot.spinner || "");
@@ -2215,12 +2234,53 @@
2215
2234
  renderEntry(`${key}:prompt`, turn.prompt, isControl ? "control" : "user");
2216
2235
  }
2217
2236
  if (turn.response) {
2218
- renderEntry(`${key}:response`, turn.response, isControl ? "control" : "response", !isControl);
2237
+ const response = renderEntry(
2238
+ `${key}:response`, turn.response, isControl ? "control" : "response", !isControl
2239
+ );
2240
+ response.dataset.finalId = isControl ? "" : (turn.final_id || "");
2241
+ response.dataset.sessionId = activeSessionId;
2219
2242
  }
2220
2243
  if (turn.thinking) renderEntry(`${key}:thinking`, turn.thinking, "thinking");
2221
2244
  if (turn.error) renderEntry(`${key}:error`, turn.error, "error");
2222
2245
  }
2223
2246
 
2247
+ async function markVisibleFinalRead() {
2248
+ if (document.visibilityState !== "visible" || !document.hasFocus()) return;
2249
+ const sessionId = activeSessionId;
2250
+ const session = sessionById(sessionId);
2251
+ if (!session || !session.unread || !session.last_final_id) return;
2252
+ const state = stateForSession(sessionId);
2253
+ if (state.readingFinalId || !log.getClientRects().length) return;
2254
+ const finalId = session.last_final_id;
2255
+ const final = Array.from(log.querySelectorAll("[data-final-id]"))
2256
+ .find(element => element.dataset.finalId === finalId && element.dataset.sessionId === sessionId);
2257
+ if (!final) return;
2258
+ const viewport = log.getBoundingClientRect();
2259
+ const rect = final.getBoundingClientRect();
2260
+ if (Math.min(rect.bottom, viewport.bottom, window.innerHeight) <= Math.max(rect.top, viewport.top, 0) ||
2261
+ Math.min(rect.right, viewport.right, window.innerWidth) <= Math.max(rect.left, viewport.left, 0)) return;
2262
+ state.readingFinalId = finalId;
2263
+ try {
2264
+ const response = await fetch(relativeUrl(`api/sessions/${encodeURIComponent(sessionId)}/read`), {
2265
+ method: "POST",
2266
+ headers: {"Content-Type": "application/json"},
2267
+ body: JSON.stringify({final_id: finalId}),
2268
+ });
2269
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
2270
+ const payload = await response.json();
2271
+ const current = sessionById(sessionId);
2272
+ const saved = payload.sessions.find(item => item.id === sessionId);
2273
+ if (current && saved && current.last_final_id === saved.last_final_id) {
2274
+ current.unread = saved.unread;
2275
+ renderTabs();
2276
+ }
2277
+ } catch (error) {
2278
+ showToast("Unable to mark reply as read: " + error.message);
2279
+ } finally {
2280
+ state.readingFinalId = "";
2281
+ }
2282
+ }
2283
+
2224
2284
  async function submitPrompt(text) {
2225
2285
  const response = await fetch(relativeUrl("api/session/message"), {
2226
2286
  method: "POST",
@@ -2363,6 +2423,7 @@
2363
2423
  if (suppressScrollSave) return;
2364
2424
  if (!activeSessionId) return;
2365
2425
  stateForSession(activeSessionId).scrollTop = log.scrollTop;
2426
+ requestAnimationFrame(markVisibleFinalRead);
2366
2427
  });
2367
2428
 
2368
2429
  splitter.addEventListener("pointerdown", startResize);
@@ -2395,17 +2456,21 @@
2395
2456
  button.addEventListener("click", () => {
2396
2457
  workspace.dataset.mobilePane = button.dataset.pane;
2397
2458
  if (button.dataset.pane === "chat") resizePrompt();
2459
+ requestAnimationFrame(markVisibleFinalRead);
2398
2460
  });
2399
2461
  });
2400
2462
  window.addEventListener("resize", () => {
2401
2463
  const current = Number(window.localStorage.getItem("pycodex.workspace.chatWidth") || 0);
2402
2464
  if (current > 0) setChatWidth(current);
2403
2465
  resizePrompt();
2466
+ requestAnimationFrame(markVisibleFinalRead);
2404
2467
  });
2468
+ window.addEventListener("focus", () => requestAnimationFrame(markVisibleFinalRead));
2405
2469
  document.addEventListener("visibilitychange", () => {
2406
2470
  if (document.visibilityState !== "visible") return;
2407
2471
  pollSession();
2408
2472
  pollBoard();
2473
+ requestAnimationFrame(markVisibleFinalRead);
2409
2474
  });
2410
2475
 
2411
2476
  loadChatWidth();
@@ -2457,7 +2522,7 @@
2457
2522
 
2458
2523
  function followCanvasLink(event) {
2459
2524
  const anchor = event.target.closest("a[href]");
2460
- if (anchor && isWorkspaceLink(anchor.getAttribute("href"), false)) {
2525
+ if (anchor && isWorkspaceLink(anchor.getAttribute("href"))) {
2461
2526
  followViewLink(event, anchor.getAttribute("href"));
2462
2527
  }
2463
2528
  }
@@ -15,6 +15,8 @@
15
15
  --line: #e1e6ed;
16
16
  --accent: #385bc7;
17
17
  --accent-strong: #2c49a6;
18
+ --green: #26734f;
19
+ --green-soft: #eaf5ee;
18
20
  --danger: #b42318;
19
21
  --danger-soft: #fff0ed;
20
22
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
@@ -29,6 +31,8 @@
29
31
  --line: #303c4d;
30
32
  --accent: #a0b8ff;
31
33
  --accent-strong: #bfd0ff;
34
+ --green: #8ad5ad;
35
+ --green-soft: #233b31;
32
36
  --danger: #ff8c7f;
33
37
  --danger-soft: #321d1b;
34
38
  }
@@ -155,6 +159,9 @@
155
159
  .status.error {
156
160
  color: var(--danger);
157
161
  }
162
+ .action-status { max-width: none; margin: 0 0 16px; text-align: left; }
163
+ .action-status:empty { display: none; }
164
+ .workspace-list.stale { opacity: .6; }
158
165
  .layout {
159
166
  display: grid;
160
167
  grid-template-columns: minmax(0, 1fr) 280px;
@@ -241,12 +248,14 @@
241
248
  }
242
249
  .list-head { position: sticky; top: 0; z-index: 1; border-radius: 9px 9px 0 0; background: var(--panel-soft); color: var(--muted); font-size: 10px; font-weight: 550; letter-spacing: .3px; }
243
250
  .list-head span:last-child { text-align: right; }
244
- .workspace-row { min-height: 62px; border-top: 1px solid var(--line); }
251
+ .workspace-row { position: relative; min-height: 62px; border-top: 1px solid var(--line); }
245
252
  .workspace-row:hover { background: var(--panel-soft); }
246
253
  .workspace-row:last-child { border-radius: 0 0 9px 9px; }
247
254
  .workspace-name {
248
255
  min-width: 0;
249
256
  }
257
+ .workspace-status { display: inline-flex; align-items: center; gap: 6px; margin-left: 8px; vertical-align: middle; }
258
+ .unread-dot { flex: 0 0 6px; width: 6px; height: 6px; border-radius: 50%; background: var(--danger); }
250
259
  .workspace-name strong {
251
260
  display: block;
252
261
  overflow: hidden;
@@ -263,6 +272,16 @@
263
272
  font-size: 11px;
264
273
  line-height: 1.5;
265
274
  }
275
+ .activity-badge { display: inline-flex; align-items: center; gap: 5px; padding: 3px 6px; border-radius: 4px; color: var(--muted); background: var(--panel-soft); font-size: 10px; font-weight: 500; }
276
+ .activity-badge[data-state="working"] { color: var(--green); background: var(--green-soft); }
277
+ .activity-badge[data-state="working"]::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; animation: spinner-pulse 1.2s cubic-bezier(0.4, 0, 0.2, 1) infinite; }
278
+ @keyframes spinner-pulse {
279
+ 0%, 100% { opacity: .45; transform: scale(.82); }
280
+ 50% { opacity: 1; transform: scale(1.08); }
281
+ }
282
+ @media (prefers-reduced-motion: reduce) {
283
+ .activity-badge[data-state="working"]::before { animation: none; opacity: .8; transform: none; }
284
+ }
266
285
  .paths {
267
286
  display: grid;
268
287
  gap: 2px;
@@ -295,6 +314,8 @@
295
314
  }
296
315
  .actions a.button { color: var(--accent); border-color: var(--line); }
297
316
  .actions a.button:hover { background: var(--panel-soft); }
317
+ .actions a.button::after { content: ""; position: absolute; inset: 0; }
318
+ .actions button { position: relative; z-index: 1; }
298
319
  .empty {
299
320
  display: grid;
300
321
  justify-items: center;
@@ -334,12 +355,13 @@
334
355
  <span>pycodex</span>
335
356
  <span class="brand-context">workspaces</span>
336
357
  </a>
337
- <div id="status" class="status" role="status"></div>
358
+ <div id="status" class="status" role="status">Connecting…</div>
338
359
  </header>
339
360
  <div class="intro">
340
361
  <h1>Your workspaces</h1>
341
362
  <p>Manage workspaces on this device.</p>
342
363
  </div>
364
+ <p id="actionStatus" class="status action-status" role="status"></p>
343
365
  <div class="layout">
344
366
  <section class="panel" aria-labelledby="listTitle">
345
367
  <div class="panel-head">
@@ -353,7 +375,7 @@
353
375
  </div>
354
376
  <div class="metric">
355
377
  <span id="activeCount" class="metric-value">0</span>
356
- <span class="metric-label">running</span>
378
+ <span class="metric-label">working</span>
357
379
  </div>
358
380
  </div>
359
381
  </div>
@@ -402,6 +424,7 @@
402
424
  const rows = document.getElementById("workspaceRows");
403
425
  const form = document.getElementById("workspaceForm");
404
426
  const statusEl = document.getElementById("status");
427
+ const actionStatus = document.getElementById("actionStatus");
405
428
  const nameInput = document.getElementById("nameInput");
406
429
  const dirInput = document.getElementById("dirInput");
407
430
  const statePathInput = document.getElementById("statePathInput");
@@ -417,8 +440,8 @@
417
440
  }
418
441
 
419
442
  function setStatus(text, isError = false) {
420
- statusEl.textContent = text || "";
421
- statusEl.classList.toggle("error", Boolean(isError));
443
+ actionStatus.textContent = text || "";
444
+ actionStatus.classList.toggle("error", Boolean(isError));
422
445
  }
423
446
 
424
447
  function textNode(tag, className, text) {
@@ -445,7 +468,9 @@
445
468
  });
446
469
  workspaceCount.textContent = String(workspaces.length);
447
470
  sessionCount.textContent = String(sessions.length);
448
- activeCount.textContent = String(sessions.filter((session) => session.running).length);
471
+ activeCount.textContent = String(workspaces.filter(
472
+ workspace => workspace.sessions.some(session => session.running)
473
+ ).length);
449
474
  }
450
475
 
451
476
  function renderEmpty() {
@@ -459,6 +484,7 @@
459
484
  function renderWorkspace(workspace) {
460
485
  const row = document.createElement("article");
461
486
  row.className = "workspace-row";
487
+ row.dataset.workspaceId = workspace.id;
462
488
 
463
489
  const name = document.createElement("div");
464
490
  name.className = "workspace-name";
@@ -467,6 +493,14 @@
467
493
  name.appendChild(title);
468
494
  const sessions = Array.isArray(workspace.sessions) ? workspace.sessions : [];
469
495
  name.appendChild(textNode("span", "session-count", `${sessions.length} session${sessions.length === 1 ? "" : "s"}`));
496
+ const status = textNode("span", "workspace-status", "");
497
+ const unread = textNode("span", "unread-dot", "");
498
+ unread.title = "Unread reply";
499
+ unread.setAttribute("role", "img");
500
+ unread.setAttribute("aria-label", "Unread reply");
501
+ status.appendChild(unread);
502
+ status.appendChild(textNode("span", "activity-badge", ""));
503
+ name.appendChild(status);
470
504
 
471
505
  const paths = document.createElement("div");
472
506
  paths.className = "paths";
@@ -491,28 +525,60 @@
491
525
  row.appendChild(paths);
492
526
  row.appendChild(actions);
493
527
  rows.appendChild(row);
528
+ return row;
494
529
  }
495
530
 
496
531
  function render(workspaces) {
497
- rows.textContent = "";
532
+ const existing = new Map(
533
+ Array.from(rows.querySelectorAll(".workspace-row"), row => [row.dataset.workspaceId, row])
534
+ );
498
535
  renderSummary(workspaces);
499
536
  if (!workspaces.length) {
537
+ rows.textContent = "";
500
538
  renderEmpty();
501
539
  return;
502
540
  }
503
- workspaces.forEach(renderWorkspace);
541
+ const empty = rows.querySelector(".empty");
542
+ if (empty) empty.remove();
543
+ workspaces.forEach((workspace, index) => {
544
+ const row = existing.get(workspace.id) || renderWorkspace(workspace);
545
+ existing.delete(workspace.id);
546
+ const count = workspace.sessions.length;
547
+ row.querySelector(".session-count").textContent = `${count} session${count === 1 ? "" : "s"}`;
548
+ const running = workspace.sessions.some(session => session.running);
549
+ const activity = row.querySelector(".activity-badge");
550
+ activity.dataset.state = running ? "working" : "ready";
551
+ activity.textContent = running ? "Working" : "Ready";
552
+ row.querySelector(".unread-dot").hidden = !workspace.sessions.some(session => session.unread);
553
+ if (rows.children[index] !== row) rows.insertBefore(row, rows.children[index] || null);
554
+ });
555
+ existing.forEach(row => row.remove());
504
556
  }
505
557
 
506
- async function refresh() {
507
- setStatus("Refreshing...");
508
- const response = await fetch(api("api/workspaces"));
509
- const payload = await response.json();
510
- if (!response.ok) {
511
- throw new Error(payload.error || `HTTP ${response.status}`);
512
- }
513
- render(Array.isArray(payload.workspaces) ? payload.workspaces : []);
514
- setStatus("");
515
- }
558
+ const refresh = (() => {
559
+ let refreshing = false;
560
+ return async () => {
561
+ if (refreshing) return;
562
+ refreshing = true;
563
+ try {
564
+ const response = await fetch(api("api/workspaces"), {cache: "no-store"});
565
+ const payload = await response.json();
566
+ if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`);
567
+ render(payload.workspaces);
568
+ statusEl.textContent = "Live · updates automatically";
569
+ statusEl.title = "";
570
+ statusEl.classList.remove("error");
571
+ rows.classList.remove("stale");
572
+ } catch (error) {
573
+ statusEl.textContent = "Could not refresh · retrying";
574
+ statusEl.title = error.message;
575
+ statusEl.classList.add("error");
576
+ rows.classList.add("stale");
577
+ } finally {
578
+ refreshing = false;
579
+ }
580
+ };
581
+ })();
516
582
 
517
583
  async function addWorkspace(event) {
518
584
  event.preventDefault();
@@ -558,8 +624,9 @@
558
624
  }
559
625
 
560
626
  form.addEventListener("submit", addWorkspace);
561
- refreshButton.addEventListener("click", () => refresh().catch((error) => setStatus(error.message, true)));
562
- refresh().catch((error) => setStatus(error.message, true));
627
+ refreshButton.addEventListener("click", refresh);
628
+ refresh();
629
+ setInterval(refresh, 2000);
563
630
  </script>
564
631
  </body>
565
632
  </html>
@@ -233,6 +233,7 @@ class WorkspaceSessionManager:
233
233
  self._session_order: "typing.List[str]" = []
234
234
  self._state_watchers: "typing.Dict[str, asyncio.Task]" = {}
235
235
  self._tab_states: "typing.Dict[str, typing.Dict[str, object]]" = {}
236
+ self._read_final_ids: "typing.Dict[str, str]" = {}
236
237
  self._lock = None
237
238
  self._state_store = WorkspaceStateStore(
238
239
  workspace_file_path(state_path, ".pycodex-ws.json")
@@ -285,6 +286,7 @@ class WorkspaceSessionManager:
285
286
  self._session_order = []
286
287
  self._state_watchers.clear()
287
288
  self._tab_states.clear()
289
+ self._read_final_ids.clear()
288
290
 
289
291
  async def create_session(
290
292
  self,
@@ -342,6 +344,7 @@ class WorkspaceSessionManager:
342
344
  raise KeyError(session_id)
343
345
  watcher = self._state_watchers.pop(session_id, None)
344
346
  self._tab_states.pop(session_id, None)
347
+ self._read_final_ids.pop(session_id, None)
345
348
  self._session_order = [
346
349
  item for item in self._session_order if item != session_id
347
350
  ]
@@ -398,16 +401,28 @@ class WorkspaceSessionManager:
398
401
  raise KeyError("no sessions")
399
402
  return self._session_order[0]
400
403
 
404
+ def mark_read(self, session_id: str, final_id: str) -> None:
405
+ if not isinstance(final_id, str) or not 0 < len(final_id) <= 128:
406
+ raise ValueError("final_id must be a nonempty string of at most 128 characters")
407
+ session = self._sessions[session_id]
408
+ if final_id == session_summary(session).get("last_final_id"):
409
+ self._read_final_ids[session_id] = final_id
410
+
401
411
  def list_sessions(self) -> "typing.List[typing.Dict[str, object]]":
402
412
  result = []
403
413
  for session_id in self._session_order:
404
414
  session = self._sessions[session_id]
405
415
  summary = session_summary(session)
416
+ final_id = summary.get("last_final_id") or ""
406
417
  result.append(
407
418
  {
408
419
  "id": session_id,
409
420
  "title": summary.get("title") or "pycodex",
410
421
  "running": bool(summary.get("running")),
422
+ "last_final_id": final_id,
423
+ "unread": bool(
424
+ final_id and final_id != self._read_final_ids.get(session_id)
425
+ ),
411
426
  "spinner": summary.get("spinner") or "",
412
427
  "turn_count": summary.get("turn_count") or 0,
413
428
  "last_assistant": summary.get("last_assistant") or "",
@@ -581,6 +596,7 @@ def session_summary(session) -> "typing.Dict[str, object]":
581
596
  return {
582
597
  "title": snapshot.get("title") or "",
583
598
  "running": bool(snapshot.get("running")),
599
+ "last_final_id": snapshot.get("last_final_id") or "",
584
600
  "spinner": snapshot.get("spinner") or "",
585
601
  "turn_count": len(snapshot.get("turns") or []),
586
602
  "last_assistant": _last_assistant_text(snapshot.get("turns") or []),
File without changes
File without changes
File without changes