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.
- {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/PKG-INFO +2 -2
- {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/pycodex_router/client.py +1 -0
- {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/pycodex_router/index.html +20 -6
- {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/pycodex_router/server.py +2 -0
- {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/pyproject.toml +2 -2
- {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/app.py +35 -0
- {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/workspace.html +69 -4
- {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/workspaces.html +87 -20
- {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/workspaces.py +16 -0
- {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/.gitignore +0 -0
- {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/LICENSE +0 -0
- {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/README.md +0 -0
- {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/pycodex_router/__init__.py +0 -0
- {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/pycodex_router/__main__.py +0 -0
- {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/pycodex_router/browser.py +0 -0
- {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/pycodex_router/protocol.py +0 -0
- {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/pycodex_ws/__init__.py +0 -0
- {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/pycodex_ws/feishu_card.py +0 -0
- {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/pycodex_ws/feishu_link.py +0 -0
- {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/pycodex_ws/feishu_oauth.py +0 -0
- {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/__init__.py +0 -0
- {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/__main__.py +0 -0
- {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/content_views.LICENSE.txt +0 -0
- {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/content_views.css +0 -0
- {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/content_views.mjs +0 -0
- {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/editor.css +0 -0
- {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/editor.mjs +0 -0
- {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/frontend/.gitignore +0 -0
- {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/frontend/build.mjs +0 -0
- {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/frontend/composer.mjs +0 -0
- {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/frontend/content_views.css +0 -0
- {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/frontend/content_views.mjs +0 -0
- {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/frontend/editor.css +0 -0
- {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/frontend/editor.mjs +0 -0
- {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/frontend/editor_context.mjs +0 -0
- {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/frontend/package-lock.json +0 -0
- {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/frontend/package.json +0 -0
- {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/frontend/view_labels.mjs +0 -0
- {pycodex_ws-0.4.2 → pycodex_ws-0.4.3}/workspace_server/owned_process.py +0 -0
- {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.
|
|
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.
|
|
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)
|
|
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 {
|
|
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
|
-
.
|
|
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="
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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")
|
|
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"
|
|
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">
|
|
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
|
-
|
|
421
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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",
|
|
562
|
-
refresh()
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|