pycodex-ws 0.4.4__tar.gz → 0.4.5__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 (41) hide show
  1. pycodex_ws-0.4.4/README.md → pycodex_ws-0.4.5/PKG-INFO +24 -0
  2. pycodex_ws-0.4.4/PKG-INFO → pycodex_ws-0.4.5/README.md +13 -11
  3. {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/pycodex_router/index.html +86 -19
  4. {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/pycodex_router/server.py +3 -5
  5. {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/pyproject.toml +2 -2
  6. {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/app.py +46 -9
  7. {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/workspace.html +6 -2
  8. {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/.gitignore +0 -0
  9. {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/LICENSE +0 -0
  10. {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/pycodex_router/__init__.py +0 -0
  11. {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/pycodex_router/__main__.py +0 -0
  12. {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/pycodex_router/browser.py +0 -0
  13. {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/pycodex_router/client.py +0 -0
  14. {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/pycodex_router/protocol.py +0 -0
  15. {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/pycodex_ws/__init__.py +0 -0
  16. {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/pycodex_ws/feishu_card.py +0 -0
  17. {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/pycodex_ws/feishu_link.py +0 -0
  18. {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/pycodex_ws/feishu_oauth.py +0 -0
  19. {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/__init__.py +0 -0
  20. {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/__main__.py +0 -0
  21. {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/content_views.LICENSE.txt +0 -0
  22. {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/content_views.css +0 -0
  23. {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/content_views.mjs +0 -0
  24. {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/editor.css +0 -0
  25. {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/editor.mjs +0 -0
  26. {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/frontend/.gitignore +0 -0
  27. {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/frontend/build.mjs +0 -0
  28. {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/frontend/composer.mjs +0 -0
  29. {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/frontend/content_views.css +0 -0
  30. {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/frontend/content_views.mjs +0 -0
  31. {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/frontend/editor.css +0 -0
  32. {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/frontend/editor.mjs +0 -0
  33. {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/frontend/editor_context.mjs +0 -0
  34. {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/frontend/package-lock.json +0 -0
  35. {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/frontend/package.json +0 -0
  36. {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/frontend/touch_selection.mjs +0 -0
  37. {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/frontend/view_labels.mjs +0 -0
  38. {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/owned_process.py +0 -0
  39. {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/workspace_views.py +0 -0
  40. {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/workspaces.html +0 -0
  41. {pycodex_ws-0.4.4 → pycodex_ws-0.4.5}/workspace_server/workspaces.py +0 -0
@@ -1,3 +1,14 @@
1
+ Metadata-Version: 2.1
2
+ Name: pycodex-ws
3
+ Version: 0.4.5
4
+ Summary: Browser workspace and optional Feishu integration for python-codex
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.7
7
+ Requires-Dist: pillow>=9.0.0
8
+ Requires-Dist: python-codex==0.4.5
9
+ Requires-Dist: websockets<16,>=11
10
+ Description-Content-Type: text/markdown
11
+
1
12
  # pycodex-ws
2
13
 
3
14
  `pycodex-ws` provides the browser workspace, Canvas and file/diff viewer,
@@ -84,6 +95,19 @@ The resolved `state_path` identifies the workspace; two entries cannot share it.
84
95
  Creating a workspace through the management page or API without a prefix
85
96
  generates a `/tmp/pcws-*` prefix and saves it in the configuration.
86
97
 
98
+ The router directory collects unread workspace shortcuts and their device names
99
+ in a distinct Unread panel at the top. Read workspaces leave that panel; the
100
+ complete device groups keep their device id and workspace configuration order.
101
+ Device groups start collapsed, retain their expanded state during updates, and
102
+ show Manage and the number of working workspaces in their headings. Click the
103
+ device name to rename it; aliases must be nonblank and contain 1 to 128
104
+ characters. Device headings and the Online devices list share an unread dot
105
+ whenever any of that device's workspaces is unread. Final replies and failed
106
+ turns both trigger shared unread dots; viewing the reply or error marks it as read.
107
+
108
+ Login pages state whether you are logging in to the router or workspace service
109
+ and show the server hostname and access address.
110
+
87
111
  `enable_board` defaults to `true`. Setting it to `false` disables the Canvas
88
112
  tab, HTML/image/status routes, polling and Canvas context injection. Sessions
89
113
  and file views still save and restore from the same prefix, so changing this
@@ -1,14 +1,3 @@
1
- Metadata-Version: 2.1
2
- Name: pycodex-ws
3
- Version: 0.4.4
4
- Summary: Browser workspace and optional Feishu integration for python-codex
5
- License-File: LICENSE
6
- Requires-Python: >=3.7
7
- Requires-Dist: pillow>=9.0.0
8
- Requires-Dist: python-codex==0.4.4
9
- Requires-Dist: websockets<16,>=11
10
- Description-Content-Type: text/markdown
11
-
12
1
  # pycodex-ws
13
2
 
14
3
  `pycodex-ws` provides the browser workspace, Canvas and file/diff viewer,
@@ -95,6 +84,19 @@ The resolved `state_path` identifies the workspace; two entries cannot share it.
95
84
  Creating a workspace through the management page or API without a prefix
96
85
  generates a `/tmp/pcws-*` prefix and saves it in the configuration.
97
86
 
87
+ The router directory collects unread workspace shortcuts and their device names
88
+ in a distinct Unread panel at the top. Read workspaces leave that panel; the
89
+ complete device groups keep their device id and workspace configuration order.
90
+ Device groups start collapsed, retain their expanded state during updates, and
91
+ show Manage and the number of working workspaces in their headings. Click the
92
+ device name to rename it; aliases must be nonblank and contain 1 to 128
93
+ characters. Device headings and the Online devices list share an unread dot
94
+ whenever any of that device's workspaces is unread. Final replies and failed
95
+ turns both trigger shared unread dots; viewing the reply or error marks it as read.
96
+
97
+ Login pages state whether you are logging in to the router or workspace service
98
+ and show the server hostname and access address.
99
+
98
100
  `enable_board` defaults to `true`. Setting it to `false` disables the Canvas
99
101
  tab, HTML/image/status routes, polling and Canvas context injection. Sessions
100
102
  and file views still save and restore from the same prefix, so changing this
@@ -17,6 +17,8 @@
17
17
  --green: #26734f;
18
18
  --green-soft: #eaf5ee;
19
19
  --danger: #b54739;
20
+ --unread-bg: #fff7f4;
21
+ --unread-line: #ead0c8;
20
22
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
21
23
  }
22
24
  @media (prefers-color-scheme: dark) {
@@ -31,12 +33,14 @@
31
33
  --green: #8ad5ad;
32
34
  --green-soft: #233b31;
33
35
  --danger: #ffa396;
36
+ --unread-bg: #2b2221;
37
+ --unread-line: #62433d;
34
38
  }
35
39
  }
36
40
  * { box-sizing: border-box; }
37
41
  body { margin: 0; min-height: 100vh; background: var(--bg); color: var(--ink); font-size: 13px; }
38
42
  a { color: inherit; text-decoration: none; }
39
- a:focus-visible, .device-label:focus-visible { outline: 2px solid var(--accent); outline-offset: 5px; border-radius: 3px; }
43
+ a:focus-visible, .device-label:focus-visible, .workspace-group-heading:focus-visible { outline: 2px solid var(--accent); outline-offset: 5px; border-radius: 3px; }
40
44
  svg { width: 20px; height: 20px; flex: 0 0 auto; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
41
45
  h1, h2, h3, h4, p { margin: 0; }
42
46
  code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
@@ -70,7 +74,10 @@
70
74
  #activity { color: var(--green); font-size: 12px; }
71
75
  #workspaces { display: grid; gap: 16px; }
72
76
  .workspace-table, .nodes { background: var(--panel); border: 1px solid var(--line); border-radius: 9px; }
73
- .workspace-group-heading { display: flex; align-items: center; gap: 10px; padding: 12px 14px; }
77
+ .workspace-group-heading { display: flex; align-items: center; gap: 10px; padding: 12px 14px; cursor: pointer; list-style: none; }
78
+ .workspace-group-heading::-webkit-details-marker { display: none; }
79
+ .disclosure-icon { width: 14px; height: 14px; color: var(--muted); }
80
+ .workspace-group[open] .disclosure-icon { transform: rotate(90deg); }
74
81
  .workspace-group-name { flex: 1; min-width: 0; font-size: 12px; font-weight: 600; line-height: 1.6; overflow-wrap: anywhere; }
75
82
  .workspace-group-heading .count { flex: 0 0 auto; }
76
83
  .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; }
@@ -79,7 +86,16 @@
79
86
  .workspace-row > * { min-width: 0; }
80
87
  .workspace-row:hover { background: var(--soft); }
81
88
  .workspace-row:last-child { border-radius: 0 0 9px 9px; }
89
+ #unread-workspaces { background: var(--unread-bg); border-color: var(--unread-line); border-left: 3px solid var(--danger); }
90
+ .unread-heading { display: flex; align-items: center; gap: 9px; padding: 14px; color: var(--danger); }
91
+ .unread-heading svg { width: 17px; height: 17px; }
92
+ .unread-heading h3 { flex: 1; font-size: 13px; font-weight: 600; }
93
+ .unread-heading .count { color: var(--panel); background: var(--danger); }
94
+ #unread-workspaces .workspace-row { border-color: var(--unread-line); }
95
+ #unread-workspaces .workspace-row:hover { background: var(--panel); }
82
96
  .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; }
97
+ .activity-badge[hidden] { display: none; }
98
+ .workspace-group-name .device-working { margin-left: 8px; white-space: nowrap; }
83
99
  .activity-badge[data-state="working"] { color: var(--green); background: var(--green-soft); }
84
100
  .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
101
  @keyframes spinner-pulse {
@@ -94,7 +110,7 @@
94
110
  .workspace-title { font-size: 12px; line-height: 1.6; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
95
111
  .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; }
96
112
  .state-path { color: var(--muted); }
97
- .session-count { color: var(--muted); font-size: 11px; line-height: 1.7; }
113
+ .session-count { display: block; color: var(--muted); font-size: 11px; line-height: 1.7; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
98
114
  .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; }
99
115
  .open-link svg, .manage-link svg { width: 13px; height: 13px; }
100
116
  .open-link::after { content: ""; position: absolute; inset: 0; }
@@ -104,10 +120,11 @@
104
120
  .device-heading { display: flex; align-items: start; gap: 9px; }
105
121
  .device-heading svg { width: 17px; height: 17px; margin-top: 1px; color: var(--muted); }
106
122
  .device-name { flex: 1; min-width: 0; font-size: 11px; font-weight: 500; line-height: 1.6; overflow-wrap: anywhere; }
107
- .device-label { display: block; width: 100%; padding: 0; border: 0; background: none; color: inherit; font: inherit; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; text-align: left; overflow-wrap: anywhere; cursor: text; }
123
+ .device-label { display: inline-block; max-width: 100%; padding: 0; border: 0; background: none; color: inherit; font: inherit; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; text-align: left; overflow-wrap: anywhere; cursor: text; }
108
124
  .device-label[hidden] { display: none; }
109
125
  .device-alias-input { width: 100%; min-width: 0; padding: 3px 6px; border: 1px solid var(--accent); border-radius: 4px; background: var(--panel); color: var(--ink); font: inherit; outline: none; }
110
126
  .online-dot { margin-top: 7px; }
127
+ .device-heading > .unread-dot { margin-top: 7px; }
111
128
  .device-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 2px 8px; margin-top: 6px; }
112
129
  .device-detail { color: var(--muted); font-size: 10px; line-height: 1.5; }
113
130
  .empty { grid-column: 1 / -1; display: grid; justify-items: center; gap: 8px; padding: 32px 20px; text-align: center; }
@@ -179,17 +196,33 @@
179
196
  </div>
180
197
  </main>
181
198
  </div>
182
- <template id="workspace-group-template">
183
- <section class="workspace-table workspace-group">
184
- <header class="workspace-group-heading">
185
- <h3 class="workspace-group-name"><button type="button" class="device-label"></button></h3>
199
+ <template id="unread-template">
200
+ <section id="unread-workspaces" class="workspace-table" aria-labelledby="unread-heading">
201
+ <header class="unread-heading">
202
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9m-8 12h4"/></svg>
203
+ <h3 id="unread-heading">Unread</h3>
186
204
  <span class="count"></span>
187
205
  </header>
206
+ <div class="workspace-group-list"></div>
207
+ </section>
208
+ </template>
209
+ <template id="workspace-group-template">
210
+ <details class="workspace-table workspace-group">
211
+ <summary class="workspace-group-heading">
212
+ <svg class="disclosure-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m9 6 6 6-6 6"/></svg>
213
+ <h3 class="workspace-group-name">
214
+ <button type="button" class="device-label"></button>
215
+ <span class="activity-badge device-working" data-state="working" hidden></span>
216
+ </h3>
217
+ <span class="unread-dot device-unread" hidden role="img" aria-label="Unread workspaces" title="Unread workspaces"></span>
218
+ <span class="count"></span>
219
+ <a class="manage-link">Manage <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14m-6-6 6 6-6 6"/></svg></a>
220
+ </summary>
188
221
  <div class="list-head" aria-hidden="true">
189
222
  <span>Workspace</span><span>Directory / state</span><span>Status</span><span></span>
190
223
  </div>
191
224
  <div class="workspace-group-list"></div>
192
- </section>
225
+ </details>
193
226
  </template>
194
227
  <template id="workspace-template">
195
228
  <article class="workspace-row">
@@ -213,11 +246,12 @@
213
246
  <div class="device-heading">
214
247
  <svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8m-4-4v4"/></svg>
215
248
  <h3 class="device-name"><button type="button" class="device-label"></button></h3>
249
+ <span class="unread-dot device-unread" hidden role="img" aria-label="Unread workspaces" title="Unread workspaces"></span>
216
250
  <span class="online-dot" aria-label="Online"></span>
217
251
  </div>
218
252
  <div class="device-footer">
219
253
  <p class="device-detail"></p>
220
- <a class="manage-link">Manage <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14m-6-6 6 6-6 6"/></svg></a>
254
+ <span class="activity-badge device-working" data-state="working" hidden></span>
221
255
  </div>
222
256
  </article>
223
257
  </template>
@@ -235,6 +269,7 @@
235
269
  const layout = document.querySelector(".layout");
236
270
  const workspaces = document.getElementById("workspaces");
237
271
  const nodes = document.getElementById("nodes");
272
+ const unreadTemplate = document.getElementById("unread-template");
238
273
  const workspaceGroupTemplate = document.getElementById("workspace-group-template");
239
274
  const workspaceTemplate = document.getElementById("workspace-template");
240
275
  const deviceTemplate = document.getElementById("device-template");
@@ -297,9 +332,11 @@
297
332
  const input = document.createElement("input");
298
333
  input.type = "text";
299
334
  input.className = "device-alias-input";
335
+ input.minLength = 1;
300
336
  input.maxLength = 128;
337
+ input.required = true;
301
338
  input.setAttribute("aria-label", "Device alias");
302
- input.title = "Leave empty to use the original device name.";
339
+ input.title = "Use 1 to 128 characters.";
303
340
  input.value = node.alias || "";
304
341
  input.placeholder = node.name;
305
342
  label.hidden = true;
@@ -312,6 +349,7 @@
312
349
  input.remove();
313
350
  label.hidden = false;
314
351
  try {
352
+ if (save && !alias) throw new Error("Device alias must contain at least one character.");
315
353
  if (save && alias !== (node.alias || "")) {
316
354
  const response = await fetch(`api/nodes/${encodeURIComponent(node.id)}/alias`, {
317
355
  method: "POST",
@@ -346,12 +384,21 @@
346
384
  }
347
385
 
348
386
  function render(data) {
387
+ const expanded = new Set(Array.from(
388
+ workspaces.querySelectorAll(".workspace-group[open]"), group => group.dataset.nodeId));
349
389
  const workspaceList = document.createDocumentFragment();
390
+ const unreadGroup = unreadTemplate.content.firstElementChild.cloneNode(true);
391
+ const unreadList = unreadGroup.querySelector(".workspace-group-list");
350
392
  const groups = new Map();
351
393
  for (const node of data.nodes) {
352
394
  const group = workspaceGroupTemplate.content.firstElementChild.cloneNode(true);
395
+ group.dataset.nodeId = node.id;
396
+ group.open = expanded.has(node.id);
353
397
  group.setAttribute("aria-label", node.alias || node.name);
354
398
  bindDeviceLabel(group.querySelector(".device-label"), node, data);
399
+ const link = group.querySelector(".manage-link");
400
+ link.href = node.url;
401
+ link.setAttribute("aria-label", `Manage workspaces on ${node.alias || node.name}`);
355
402
  groups.set(node.id, {node, group});
356
403
  }
357
404
  for (const workspace of data.workspaces) {
@@ -375,17 +422,33 @@
375
422
  const {node, group} = groups.get(workspace.node_id);
376
423
  link.setAttribute("aria-label", `Open ${workspace.id} on ${node.alias || node.name}`);
377
424
  group.querySelector(".workspace-group-list").append(card);
425
+ if (workspace.unread) {
426
+ group.querySelector(".device-unread").hidden = false;
427
+ const unreadCard = card.cloneNode(true);
428
+ const detail = unreadCard.querySelector(".session-count");
429
+ detail.textContent = `${node.alias || node.name} · ${detail.textContent}`;
430
+ detail.title = detail.textContent;
431
+ unreadList.append(unreadCard);
432
+ }
433
+ }
434
+ if (unreadList.childElementCount) {
435
+ unreadGroup.querySelector(".count").textContent = countLabel(
436
+ unreadList.childElementCount, "workspace");
437
+ workspaceList.append(unreadGroup);
378
438
  }
379
439
  for (const {group} of groups.values()) {
380
440
  const count = group.querySelector(".workspace-group-list").childElementCount;
381
- if (!count) continue;
382
441
  group.querySelector(".count").textContent = countLabel(count, "workspace");
442
+ if (!count) {
443
+ group.querySelector(".list-head").remove();
444
+ group.querySelector(".workspace-group-list").append(
445
+ empty("No workspaces yet", "Use Manage to create a workspace on this device."));
446
+ }
383
447
  workspaceList.append(group);
384
448
  }
385
- if (!data.workspaces.length) {
386
- workspaceList.append(empty("No workspaces yet", data.nodes.length
387
- ? "Open a device to create your first workspace."
388
- : "Your workspaces will appear when a device connects."));
449
+ if (!data.nodes.length) {
450
+ workspaceList.append(empty("No workspaces yet",
451
+ "Your workspaces will appear when a device connects."));
389
452
  }
390
453
 
391
454
  const nodeList = document.createDocumentFragment();
@@ -393,11 +456,15 @@
393
456
  const card = deviceTemplate.content.firstElementChild.cloneNode(true);
394
457
  bindDeviceLabel(card.querySelector(".device-label"), node, data);
395
458
  const owned = data.workspaces.filter(workspace => workspace.node_id === node.id);
459
+ card.querySelector(".device-unread").hidden = !owned.some(workspace => workspace.unread);
396
460
  const sessions = owned.reduce((total, workspace) => total + workspace.session_count, 0);
397
461
  card.querySelector(".device-detail").textContent = `${countLabel(owned.length, "workspace")} · ${countLabel(sessions, "session")}`;
398
- const link = card.querySelector(".manage-link");
399
- link.href = node.url;
400
- link.setAttribute("aria-label", `Manage workspaces on ${node.alias || node.name}`);
462
+ const working = owned.filter(workspace => workspace.running).length;
463
+ for (const container of [groups.get(node.id).group, card]) {
464
+ const badge = container.querySelector(".device-working");
465
+ badge.hidden = !working;
466
+ badge.textContent = working ? `${working} working` : "";
467
+ }
401
468
  nodeList.append(card);
402
469
  }
403
470
  if (!data.nodes.length) {
@@ -63,10 +63,11 @@ def load_registration_key(path):
63
63
  def normalize_alias(value):
64
64
  if (
65
65
  not isinstance(value, str)
66
+ or not value.strip()
66
67
  or len(value) > 128
67
68
  or any(ord(char) < 32 for char in value)
68
69
  ):
69
- raise ValueError("Device alias must be a single line of at most 128 characters.")
70
+ raise ValueError("Device alias must be a single line of 1 to 128 characters.")
70
71
  value.encode("utf-8")
71
72
  return value.strip()
72
73
 
@@ -214,10 +215,7 @@ class Router:
214
215
  except ValueError as exc:
215
216
  raise HTTPException(422, str(exc))
216
217
  aliases = dict(self.aliases)
217
- if alias:
218
- aliases[node_id] = alias
219
- else:
220
- aliases.pop(node_id, None)
218
+ aliases[node_id] = alias
221
219
  self._save_aliases(aliases)
222
220
  return JSONResponse({"alias": alias})
223
221
 
@@ -4,12 +4,12 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pycodex-ws"
7
- version = "0.4.4"
7
+ version = "0.4.5"
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.4",
12
+ "python-codex==0.4.5",
13
13
  "pillow>=9.0.0",
14
14
  "websockets>=11,<16",
15
15
  ]
@@ -7,6 +7,7 @@ import json
7
7
  import mimetypes
8
8
  import os
9
9
  import secrets
10
+ import socket
10
11
  import subprocess
11
12
  import sys
12
13
  import threading
@@ -479,6 +480,8 @@ class WebSessionView:
479
480
  if isinstance(event, TurnFailedEvent):
480
481
  turn["status"] = "error"
481
482
  turn["error"] = event.visualize()
483
+ turn["final_id"] = event.turn_id
484
+ self._last_final_id = event.turn_id
482
485
  self._stream_buffer = ""
483
486
  return
484
487
 
@@ -1132,10 +1135,15 @@ def _install_auth(
1132
1135
  )
1133
1136
 
1134
1137
  async def login_page(request: Request) -> HTMLResponse:
1135
- return _html_response(_render_login_shell(login_title))
1138
+ address = str(request.url.replace(
1139
+ path=request.scope.get("root_path", "") + "/", query="",
1140
+ ))
1141
+ return _html_response(_render_login_shell(
1142
+ login_title, socket.gethostname(), address,
1143
+ ))
1136
1144
 
1137
1145
  async def login(request: Request) -> JSONResponse:
1138
- payload = await _json_object_body(request)
1146
+ payload = await _json_object_body(request, max_bytes=4096)
1139
1147
  client_ip = request.client.host if blocked_ips is not None else None
1140
1148
  # Another login can block this IP while the request body is arriving.
1141
1149
  if blocked_ips is not None and client_ip in blocked_ips:
@@ -1182,14 +1190,25 @@ def _install_auth(
1182
1190
  return token
1183
1191
 
1184
1192
 
1185
- async def _json_object_body(request: Request) -> "typing.Dict[str, object]":
1193
+ async def _json_object_body(
1194
+ request: Request, max_bytes: "typing.Union[int, None]" = None,
1195
+ ) -> "typing.Dict[str, object]":
1186
1196
  media_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
1187
1197
  if media_type != "application/json" and not (
1188
1198
  media_type.startswith("application/") and media_type.endswith("+json")
1189
1199
  ):
1190
1200
  raise HTTPException(status_code=422, detail="request body must be JSON")
1201
+ body = bytearray()
1202
+ async for chunk in request.stream():
1203
+ if max_bytes is not None and len(body) + len(chunk) > max_bytes:
1204
+ raise HTTPException(
1205
+ status_code=413,
1206
+ detail="request body exceeds {0} bytes".format(max_bytes),
1207
+ headers={"Connection": "close"},
1208
+ )
1209
+ body.extend(chunk)
1191
1210
  try:
1192
- payload = await request.json()
1211
+ payload = json.loads(body)
1193
1212
  except (json.JSONDecodeError, UnicodeDecodeError):
1194
1213
  raise HTTPException(status_code=422, detail="invalid JSON body")
1195
1214
  if not isinstance(payload, dict):
@@ -1203,13 +1222,13 @@ def _auth_cookie_matches(token: str, cookie: "typing.Union[str, None]") -> bool:
1203
1222
  return bool(cookie) and secrets.compare_digest(str(cookie), token)
1204
1223
 
1205
1224
 
1206
- def _render_login_shell(title: str = "pycodex workspace") -> str:
1225
+ def _render_login_shell(title: str, hostname: str, address: str) -> str:
1207
1226
  return """<!doctype html>
1208
1227
  <html>
1209
1228
  <head>
1210
1229
  <meta charset="utf-8">
1211
1230
  <meta name="viewport" content="width=device-width, initial-scale=1">
1212
- <title>__LOGIN_TITLE__</title>
1231
+ <title>Log in to __LOGIN_TITLE__</title>
1213
1232
  <style>
1214
1233
  :root {
1215
1234
  color-scheme: light dark;
@@ -1232,6 +1251,12 @@ def _render_login_shell(title: str = "pycodex workspace") -> str:
1232
1251
  margin: 0;
1233
1252
  font-size: 22px;
1234
1253
  }
1254
+ .login-target {
1255
+ margin: 0;
1256
+ font-size: 13px;
1257
+ line-height: 1.6;
1258
+ overflow-wrap: anywhere;
1259
+ }
1235
1260
  input, button {
1236
1261
  min-height: 38px;
1237
1262
  border-radius: 7px;
@@ -1251,10 +1276,11 @@ def _render_login_shell(title: str = "pycodex workspace") -> str:
1251
1276
  </head>
1252
1277
  <body>
1253
1278
  <form id="loginForm">
1254
- <h1>__LOGIN_TITLE__</h1>
1279
+ <h1>Log in to __LOGIN_TITLE__</h1>
1280
+ <p class="login-target">Server: <strong>__LOGIN_HOST__</strong><br><code>__LOGIN_ADDRESS__</code></p>
1255
1281
  <label for="passwordInput">Password</label>
1256
1282
  <input id="passwordInput" type="password" autocomplete="current-password" autofocus>
1257
- <button type="submit">Open</button>
1283
+ <button type="submit">Log in</button>
1258
1284
  <div id="status" class="status" role="status"></div>
1259
1285
  </form>
1260
1286
  <script>
@@ -1280,7 +1306,9 @@ def _render_login_shell(title: str = "pycodex workspace") -> str:
1280
1306
  });
1281
1307
  </script>
1282
1308
  </body>
1283
- </html>""".replace("__LOGIN_TITLE__", html.escape(title))
1309
+ </html>""".replace("__LOGIN_TITLE__", html.escape(title)).replace(
1310
+ "__LOGIN_HOST__", html.escape(hostname),
1311
+ ).replace("__LOGIN_ADDRESS__", html.escape(address))
1284
1312
 
1285
1313
 
1286
1314
  async def _http_exception_response(request: Request, exc: HTTPException) -> JSONResponse:
@@ -1637,6 +1665,15 @@ async def _websocket_session_handler(
1637
1665
  manager: "WorkspaceSessionManager",
1638
1666
  websocket: WebSocket,
1639
1667
  ) -> None:
1668
+ url = websocket.url
1669
+ scheme = "https" if url.scheme == "wss" else "http"
1670
+ authority = url.netloc.lower()
1671
+ if url.port == (443 if scheme == "https" else 80):
1672
+ authority = authority.rsplit(":", 1)[0]
1673
+ # Browsers send Origin; non-browser clients may omit it.
1674
+ if websocket.headers.get("origin") not in (None, scheme + "://" + authority):
1675
+ await websocket.close(code=1008)
1676
+ return
1640
1677
  await websocket.accept()
1641
1678
  session_id = str(websocket.query_params.get("session_id") or "")
1642
1679
  try:
@@ -2249,11 +2249,15 @@
2249
2249
  const response = renderEntry(
2250
2250
  `${key}:response`, turn.response, isControl ? "control" : "response", !isControl
2251
2251
  );
2252
- response.dataset.finalId = isControl ? "" : (turn.final_id || "");
2252
+ response.dataset.finalId = isControl || turn.error ? "" : (turn.final_id || "");
2253
2253
  response.dataset.sessionId = activeSessionId;
2254
2254
  }
2255
2255
  if (turn.thinking) renderEntry(`${key}:thinking`, turn.thinking, "thinking");
2256
- if (turn.error) renderEntry(`${key}:error`, turn.error, "error");
2256
+ if (turn.error) {
2257
+ const error = renderEntry(`${key}:error`, turn.error, "error");
2258
+ error.dataset.finalId = isControl ? "" : (turn.final_id || "");
2259
+ error.dataset.sessionId = activeSessionId;
2260
+ }
2257
2261
  }
2258
2262
 
2259
2263
  async function markVisibleFinalRead() {
File without changes
File without changes