pycodex-ws 0.4.4__tar.gz → 0.4.6__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 → pycodex_ws-0.4.6}/PKG-INFO +29 -2
  2. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/README.md +27 -0
  3. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/pycodex_router/index.html +279 -47
  4. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/pycodex_router/server.py +41 -8
  5. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/pyproject.toml +2 -2
  6. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/app.py +46 -9
  7. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/workspace.html +6 -2
  8. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/.gitignore +0 -0
  9. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/LICENSE +0 -0
  10. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/pycodex_router/__init__.py +0 -0
  11. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/pycodex_router/__main__.py +0 -0
  12. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/pycodex_router/browser.py +0 -0
  13. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/pycodex_router/client.py +0 -0
  14. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/pycodex_router/protocol.py +0 -0
  15. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/pycodex_ws/__init__.py +0 -0
  16. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/pycodex_ws/feishu_card.py +0 -0
  17. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/pycodex_ws/feishu_link.py +0 -0
  18. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/pycodex_ws/feishu_oauth.py +0 -0
  19. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/__init__.py +0 -0
  20. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/__main__.py +0 -0
  21. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/content_views.LICENSE.txt +0 -0
  22. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/content_views.css +0 -0
  23. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/content_views.mjs +0 -0
  24. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/editor.css +0 -0
  25. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/editor.mjs +0 -0
  26. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/frontend/.gitignore +0 -0
  27. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/frontend/build.mjs +0 -0
  28. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/frontend/composer.mjs +0 -0
  29. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/frontend/content_views.css +0 -0
  30. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/frontend/content_views.mjs +0 -0
  31. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/frontend/editor.css +0 -0
  32. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/frontend/editor.mjs +0 -0
  33. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/frontend/editor_context.mjs +0 -0
  34. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/frontend/package-lock.json +0 -0
  35. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/frontend/package.json +0 -0
  36. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/frontend/touch_selection.mjs +0 -0
  37. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/frontend/view_labels.mjs +0 -0
  38. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/owned_process.py +0 -0
  39. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/workspace_views.py +0 -0
  40. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/workspaces.html +0 -0
  41. {pycodex_ws-0.4.4 → pycodex_ws-0.4.6}/workspace_server/workspaces.py +0 -0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pycodex-ws
3
- Version: 0.4.4
3
+ Version: 0.4.6
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.4
8
+ Requires-Dist: python-codex==0.4.6
9
9
  Requires-Dist: websockets<16,>=11
10
10
  Description-Content-Type: text/markdown
11
11
 
@@ -95,6 +95,33 @@ The resolved `state_path` identifies the workspace; two entries cannot share it.
95
95
  Creating a workspace through the management page or API without a prefix
96
96
  generates a `/tmp/pcws-*` prefix and saves it in the configuration.
97
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.
100
+ Drag a handle in Online devices to reorder devices; both lists share the saved
101
+ order. The focused handle also supports the up and down arrow keys. Escape or
102
+ dropping outside the list cancels a drag. On touch screens, drag the handle;
103
+ the rest of the card still scrolls normally. List updates wait until the drag
104
+ and save finish.
105
+ The router saves the order in the existing `CODEX_HOME/router-aliases.json`,
106
+ shared by all browsers and retained across router restarts. Its device entry
107
+ order is the display order; an empty alias means to show the original name.
108
+ Existing alias files remain readable. Offline device positions are
109
+ remembered, newly registered devices go last, and each device's workspaces keep
110
+ their configuration order. Reordering requires router login; if connected
111
+ devices change during a drag, refresh and try again.
112
+ Device groups start collapsed, retain their expanded state during updates, and
113
+ show Manage and the number of working workspaces in their headings. Click a
114
+ device heading or its name to expand or collapse the group. To rename a device,
115
+ use its pencil button in the Online devices list, then press Enter or Save;
116
+ Escape or Cancel discards the edit. Leaving the field does not save it.
117
+ Aliases must be nonblank and contain 1 to 128 characters.
118
+ Device headings and the Online devices list share an unread dot
119
+ whenever any of that device's workspaces is unread. Final replies and failed
120
+ turns both trigger shared unread dots; viewing the reply or error marks it as read.
121
+
122
+ Login pages state whether you are logging in to the router or workspace service
123
+ and show the server hostname and access address.
124
+
98
125
  `enable_board` defaults to `true`. Setting it to `false` disables the Canvas
99
126
  tab, HTML/image/status routes, polling and Canvas context injection. Sessions
100
127
  and file views still save and restore from the same prefix, so changing this
@@ -84,6 +84,33 @@ The resolved `state_path` identifies the workspace; two entries cannot share it.
84
84
  Creating a workspace through the management page or API without a prefix
85
85
  generates a `/tmp/pcws-*` prefix and saves it in the configuration.
86
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.
89
+ Drag a handle in Online devices to reorder devices; both lists share the saved
90
+ order. The focused handle also supports the up and down arrow keys. Escape or
91
+ dropping outside the list cancels a drag. On touch screens, drag the handle;
92
+ the rest of the card still scrolls normally. List updates wait until the drag
93
+ and save finish.
94
+ The router saves the order in the existing `CODEX_HOME/router-aliases.json`,
95
+ shared by all browsers and retained across router restarts. Its device entry
96
+ order is the display order; an empty alias means to show the original name.
97
+ Existing alias files remain readable. Offline device positions are
98
+ remembered, newly registered devices go last, and each device's workspaces keep
99
+ their configuration order. Reordering requires router login; if connected
100
+ devices change during a drag, refresh and try again.
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 a
103
+ device heading or its name to expand or collapse the group. To rename a device,
104
+ use its pencil button in the Online devices list, then press Enter or Save;
105
+ Escape or Cancel discards the edit. Leaving the field does not save it.
106
+ Aliases must be nonblank and contain 1 to 128 characters.
107
+ Device headings and the Online devices list share an unread dot
108
+ whenever any of that device's workspaces is unread. Final replies and failed
109
+ turns both trigger shared unread dots; viewing the reply or error marks it as read.
110
+
111
+ Login pages state whether you are logging in to the router or workspace service
112
+ and show the server hostname and access address.
113
+
87
114
  `enable_board` defaults to `true`. Setting it to `false` disables the Canvas
88
115
  tab, HTML/image/status routes, polling and Canvas context injection. Sessions
89
116
  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, button: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; }
@@ -60,8 +64,8 @@
60
64
  .registration-content { display: grid; gap: 8px; margin-top: 12px; }
61
65
  .registration input { width: 100%; min-width: 0; padding: 8px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); color: var(--ink); font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
62
66
  .registration p { color: var(--muted); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
63
- #alias-status { margin-bottom: 12px; color: var(--danger); font-size: 12px; }
64
- #alias-status:empty { display: none; }
67
+ #alias-status, #order-status { margin-bottom: 12px; color: var(--danger); font-size: 12px; }
68
+ #alias-status:empty, #order-status:empty { display: none; }
65
69
  .layout { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: 20px; align-items: start; }
66
70
  .layout > * { min-width: 0; }
67
71
  .section-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
@@ -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,21 @@
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; }
108
- .device-label[hidden] { display: none; }
123
+ .device-label { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
124
+ .device-rename, .device-drag, .device-alias-actions button { display: inline-flex; align-items: center; justify-content: center; min-height: 28px; padding: 4px 8px; border: 1px solid var(--line); border-radius: 4px; background: var(--panel); color: var(--muted); font: inherit; cursor: pointer; }
125
+ .device-rename:hover, .device-drag:hover, .device-alias-actions button:hover { background: var(--soft); color: var(--ink); }
126
+ .device-rename, .device-drag { flex: 0 0 28px; padding: 4px; border-color: transparent; }
127
+ .device-rename[hidden] { display: none; }
128
+ .device-rename svg, .device-drag svg { width: 14px; height: 14px; margin: 0; color: inherit; }
129
+ .device-drag { touch-action: none; user-select: none; cursor: grab; }
130
+ .device-drag:disabled { opacity: .35; cursor: default; }
131
+ .device[data-dragging] { background: var(--soft); opacity: .65; }
132
+ .device-drop-before { box-shadow: 0 -2px var(--accent); }
133
+ .nodes.device-drop-end { box-shadow: inset 0 -2px var(--accent); }
109
134
  .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; }
135
+ .device-alias-actions { display: flex; gap: 6px; margin-top: 6px; }
110
136
  .online-dot { margin-top: 7px; }
137
+ .device-heading > .unread-dot { margin-top: 7px; }
111
138
  .device-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 2px 8px; margin-top: 6px; }
112
139
  .device-detail { color: var(--muted); font-size: 10px; line-height: 1.5; }
113
140
  .empty { grid-column: 1 / -1; display: grid; justify-items: center; gap: 8px; padding: 32px 20px; text-align: center; }
@@ -129,6 +156,8 @@
129
156
  .workspace-paths { grid-column: 1 / -1; grid-row: 2; }
130
157
  .workspace-status { grid-column: 2; grid-row: 1; }
131
158
  .open-link { grid-column: 3; grid-row: 1; min-height: 36px; }
159
+ .device-rename, .device-drag, .device-alias-actions button { min-height: 36px; }
160
+ .device-rename, .device-drag { flex-basis: 36px; }
132
161
  }
133
162
  </style>
134
163
  </head>
@@ -160,6 +189,7 @@
160
189
  </div>
161
190
  </details>
162
191
  <p id="alias-status" role="alert"></p>
192
+ <p id="order-status" role="alert"></p>
163
193
  <div class="layout">
164
194
  <section aria-labelledby="workspace-heading">
165
195
  <div class="section-heading">
@@ -179,17 +209,33 @@
179
209
  </div>
180
210
  </main>
181
211
  </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>
212
+ <template id="unread-template">
213
+ <section id="unread-workspaces" class="workspace-table" aria-labelledby="unread-heading">
214
+ <header class="unread-heading">
215
+ <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>
216
+ <h3 id="unread-heading">Unread</h3>
186
217
  <span class="count"></span>
187
218
  </header>
219
+ <div class="workspace-group-list"></div>
220
+ </section>
221
+ </template>
222
+ <template id="workspace-group-template">
223
+ <details class="workspace-table workspace-group">
224
+ <summary class="workspace-group-heading">
225
+ <svg class="disclosure-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m9 6 6 6-6 6"/></svg>
226
+ <h3 class="workspace-group-name">
227
+ <span class="device-label"></span>
228
+ <span class="activity-badge device-working" data-state="working" hidden></span>
229
+ </h3>
230
+ <span class="unread-dot device-unread" hidden role="img" aria-label="Unread workspaces" title="Unread workspaces"></span>
231
+ <span class="count"></span>
232
+ <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>
233
+ </summary>
188
234
  <div class="list-head" aria-hidden="true">
189
235
  <span>Workspace</span><span>Directory / state</span><span>Status</span><span></span>
190
236
  </div>
191
237
  <div class="workspace-group-list"></div>
192
- </section>
238
+ </details>
193
239
  </template>
194
240
  <template id="workspace-template">
195
241
  <article class="workspace-row">
@@ -211,16 +257,31 @@
211
257
  <template id="device-template">
212
258
  <article class="device">
213
259
  <div class="device-heading">
214
- <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
- <h3 class="device-name"><button type="button" class="device-label"></button></h3>
260
+ <button type="button" class="device-drag" title="Drag to reorder, or use the up and down arrow keys">
261
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 5h.01M15 5h.01M9 12h.01M15 12h.01M9 19h.01M15 19h.01" stroke-width="3"/></svg>
262
+ </button>
263
+ <h3 class="device-name"><span class="device-label"></span></h3>
264
+ <button type="button" class="device-rename" title="Rename device">
265
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="m16 4 4 4M4 20l4-1L20 7a2.1 2.1 0 0 0-3-3L5 16l-1 4Z"/></svg>
266
+ </button>
267
+ <span class="unread-dot device-unread" hidden role="img" aria-label="Unread workspaces" title="Unread workspaces"></span>
216
268
  <span class="online-dot" aria-label="Online"></span>
217
269
  </div>
218
270
  <div class="device-footer">
219
271
  <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>
272
+ <span class="activity-badge device-working" data-state="working" hidden></span>
221
273
  </div>
222
274
  </article>
223
275
  </template>
276
+ <template id="device-alias-template">
277
+ <span class="device-alias-editor">
278
+ <input type="text" class="device-alias-input" minlength="1" maxlength="128" required aria-label="Device alias" title="Use 1 to 128 characters.">
279
+ <span class="device-alias-actions">
280
+ <button type="button" class="device-alias-save">Save</button>
281
+ <button type="button" class="device-alias-cancel">Cancel</button>
282
+ </span>
283
+ </span>
284
+ </template>
224
285
  <template id="empty-template">
225
286
  <div class="empty">
226
287
  <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>
@@ -235,18 +296,23 @@
235
296
  const layout = document.querySelector(".layout");
236
297
  const workspaces = document.getElementById("workspaces");
237
298
  const nodes = document.getElementById("nodes");
299
+ const unreadTemplate = document.getElementById("unread-template");
238
300
  const workspaceGroupTemplate = document.getElementById("workspace-group-template");
239
301
  const workspaceTemplate = document.getElementById("workspace-template");
240
302
  const deviceTemplate = document.getElementById("device-template");
303
+ const deviceAliasTemplate = document.getElementById("device-alias-template");
241
304
  const emptyTemplate = document.getElementById("empty-template");
242
305
  const deviceConnection = document.getElementById("device-connection");
243
306
  const registrationKey = document.getElementById("registration-key");
244
307
  const registrationStatus = document.getElementById("registration-status");
245
308
  const aliasStatus = document.getElementById("alias-status");
309
+ const orderStatus = document.getElementById("order-status");
246
310
  let refreshing = false;
247
311
  let signature = "";
248
312
  let editingDevice = null;
249
- let aliasRevision = 0;
313
+ let inventoryRevision = 0;
314
+ let reordering = false;
315
+ let drag = null;
250
316
 
251
317
  registrationKey.addEventListener("focus", () => registrationKey.select());
252
318
  deviceConnection.addEventListener("toggle", async () => {
@@ -283,35 +349,32 @@
283
349
  layout.dataset.stale = String(state === "error");
284
350
  }
285
351
 
286
- function bindDeviceLabel(label, node, data) {
352
+ function setDeviceLabel(label, node) {
287
353
  label.textContent = node.alias || node.name;
288
- label.title = `${node.name}\nClick to rename device`;
289
- label.setAttribute("aria-label", `Rename device ${node.alias || node.name}`);
290
- label.addEventListener("click", () => editDeviceAlias(label, node, data));
354
+ label.title = node.name;
291
355
  }
292
356
 
293
- function editDeviceAlias(label, node, data) {
294
- if (editingDevice !== null) return;
357
+ function editDeviceAlias(label, button, node, data) {
358
+ if (editingDevice !== null || reordering) return;
295
359
  editingDevice = node.id;
296
360
  aliasStatus.textContent = "";
297
- const input = document.createElement("input");
298
- input.type = "text";
299
- input.className = "device-alias-input";
300
- input.maxLength = 128;
301
- input.setAttribute("aria-label", "Device alias");
302
- input.title = "Leave empty to use the original device name.";
361
+ const editor = deviceAliasTemplate.content.firstElementChild.cloneNode(true);
362
+ const input = editor.querySelector("input");
303
363
  input.value = node.alias || "";
304
364
  input.placeholder = node.name;
305
365
  label.hidden = true;
306
- label.before(input);
366
+ button.hidden = true;
367
+ label.before(editor);
307
368
  let finished = false;
308
369
  const finish = async save => {
309
370
  if (finished) return;
310
371
  finished = true;
311
372
  const alias = input.value.trim();
312
- input.remove();
373
+ editor.remove();
313
374
  label.hidden = false;
375
+ button.hidden = false;
314
376
  try {
377
+ if (save && !alias) throw new Error("Device alias must contain at least one character.");
315
378
  if (save && alias !== (node.alias || "")) {
316
379
  const response = await fetch(`api/nodes/${encodeURIComponent(node.id)}/alias`, {
317
380
  method: "POST",
@@ -321,7 +384,7 @@
321
384
  if (!response.ok) throw new Error(await response.text());
322
385
  const result = await response.json();
323
386
  node.alias = result.alias;
324
- aliasRevision += 1;
387
+ inventoryRevision += 1;
325
388
  render(data);
326
389
  signature = JSON.stringify(data);
327
390
  }
@@ -329,29 +392,168 @@
329
392
  aliasStatus.textContent = `Could not rename device: ${error.message}`;
330
393
  } finally {
331
394
  editingDevice = null;
395
+ nodes.querySelector(`.device[data-node-id="${CSS.escape(node.id)}"] .device-rename`).focus();
332
396
  refresh();
333
397
  }
334
398
  };
335
- input.addEventListener("keydown", event => {
399
+ editor.addEventListener("keydown", event => {
336
400
  if (event.isComposing) return;
337
- if (event.key === "Enter" || event.key === "Escape") {
401
+ if (event.key === "Escape" || (event.key === "Enter" && event.target === input)) {
338
402
  event.preventDefault();
339
403
  finish(event.key === "Enter");
340
- label.focus();
341
404
  }
342
405
  });
343
- input.addEventListener("blur", () => finish(true));
406
+ editor.querySelector(".device-alias-save").addEventListener("click", () => finish(true));
407
+ editor.querySelector(".device-alias-cancel").addEventListener("click", () => finish(false));
344
408
  input.focus();
345
409
  input.select();
346
410
  }
347
411
 
412
+ async function saveDeviceOrder(data, order, nodeId) {
413
+ reordering = true;
414
+ orderStatus.textContent = "";
415
+ try {
416
+ if (order.some((id, index) => id !== data.nodes[index].id)) {
417
+ const response = await fetch("api/nodes/order", {
418
+ method: "POST",
419
+ headers: {"Content-Type": "application/json"},
420
+ body: JSON.stringify({order}),
421
+ });
422
+ if (!response.ok) throw new Error(await response.text());
423
+ const result = await response.json();
424
+ const byId = new Map(data.nodes.map(node => [node.id, node]));
425
+ data.nodes = result.order.map(id => byId.get(id));
426
+ data.workspaces = result.order.flatMap(
427
+ id => data.workspaces.filter(workspace => workspace.node_id === id));
428
+ inventoryRevision += 1;
429
+ render(data);
430
+ signature = JSON.stringify(data);
431
+ }
432
+ } catch (error) {
433
+ orderStatus.textContent = `Could not reorder devices: ${error.message}`;
434
+ } finally {
435
+ reordering = false;
436
+ nodes.querySelector(`.device[data-node-id="${CSS.escape(nodeId)}"] .device-drag`).focus({preventScroll: true});
437
+ refresh();
438
+ }
439
+ }
440
+
441
+ function clearDropMarker() {
442
+ nodes.classList.remove("device-drop-end");
443
+ for (const card of nodes.querySelectorAll(".device-drop-before")) {
444
+ card.classList.remove("device-drop-before");
445
+ }
446
+ }
447
+
448
+ function markDropTarget() {
449
+ clearDropMarker();
450
+ drag.before = Array.from(nodes.children).find(card => {
451
+ const rect = card.getBoundingClientRect();
452
+ return card !== drag.card && drag.y < (rect.top + rect.bottom) / 2;
453
+ });
454
+ (drag.before || nodes).classList.add(drag.before ? "device-drop-before" : "device-drop-end");
455
+ }
456
+
457
+ function scrollDrag() {
458
+ const step = drag.y < 56 ? -12 : drag.y > innerHeight - 56 ? 12 : 0;
459
+ if (step) {
460
+ window.scrollBy(0, step);
461
+ markDropTarget();
462
+ }
463
+ drag.frame = requestAnimationFrame(scrollDrag);
464
+ }
465
+
466
+ function finishDrag(commit, event) {
467
+ const current = drag;
468
+ if (!current) return;
469
+ drag = null;
470
+ cancelAnimationFrame(current.frame);
471
+ current.card.removeAttribute("data-dragging");
472
+ clearDropMarker();
473
+ if (nodes.hasPointerCapture(current.pointerId)) {
474
+ nodes.releasePointerCapture(current.pointerId);
475
+ }
476
+ const rect = nodes.getBoundingClientRect();
477
+ if (commit && current.moved && event.clientX >= rect.left
478
+ && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom) {
479
+ const cards = Array.from(nodes.children).filter(card => card !== current.card);
480
+ const index = current.before ? cards.indexOf(current.before) : cards.length;
481
+ cards.splice(index, 0, current.card);
482
+ saveDeviceOrder(current.data, cards.map(card => card.dataset.nodeId), current.card.dataset.nodeId);
483
+ } else {
484
+ reordering = false;
485
+ refresh();
486
+ }
487
+ }
488
+
489
+ nodes.addEventListener("pointermove", event => {
490
+ if (!drag || event.pointerId !== drag.pointerId) return;
491
+ drag.y = event.clientY;
492
+ if (!drag.moved) {
493
+ if (Math.hypot(event.clientX - drag.x, event.clientY - drag.startY) < 5) return;
494
+ drag.moved = true;
495
+ drag.card.setAttribute("data-dragging", "");
496
+ drag.frame = requestAnimationFrame(scrollDrag);
497
+ }
498
+ markDropTarget();
499
+ });
500
+ nodes.addEventListener("pointerup", event => {
501
+ if (drag && event.pointerId === drag.pointerId) finishDrag(true, event);
502
+ });
503
+ nodes.addEventListener("pointercancel", () => finishDrag(false));
504
+ nodes.addEventListener("lostpointercapture", () => finishDrag(false));
505
+ nodes.addEventListener("keydown", event => {
506
+ if (drag && event.key === "Escape") {
507
+ event.preventDefault();
508
+ finishDrag(false);
509
+ }
510
+ });
511
+
512
+ function bindDeviceDrag(card, node, data) {
513
+ const handle = card.querySelector(".device-drag");
514
+ handle.disabled = data.nodes.length < 2;
515
+ handle.setAttribute("aria-label", `Reorder device ${node.alias || node.name}`);
516
+ handle.addEventListener("pointerdown", event => {
517
+ if (event.button !== 0 || reordering || editingDevice !== null) return;
518
+ event.preventDefault();
519
+ handle.focus({preventScroll: true});
520
+ reordering = true;
521
+ orderStatus.textContent = "";
522
+ drag = {card, data, pointerId: event.pointerId, x: event.clientX,
523
+ startY: event.clientY, y: event.clientY, moved: false, frame: null, before: null};
524
+ nodes.setPointerCapture(event.pointerId);
525
+ });
526
+ handle.addEventListener("keydown", event => {
527
+ if (reordering || editingDevice !== null || !["ArrowUp", "ArrowDown"].includes(event.key)) return;
528
+ event.preventDefault();
529
+ const order = data.nodes.map(item => item.id);
530
+ const index = order.indexOf(node.id);
531
+ const target = index + (event.key === "ArrowUp" ? -1 : 1);
532
+ if (target < 0 || target >= order.length) return;
533
+ [order[index], order[target]] = [order[target], order[index]];
534
+ saveDeviceOrder(data, order, node.id);
535
+ });
536
+ }
537
+
348
538
  function render(data) {
539
+ const focusedHandle = nodes.contains(document.activeElement)
540
+ && document.activeElement.matches(".device-drag")
541
+ ? document.activeElement.closest(".device").dataset.nodeId : null;
542
+ const expanded = new Set(Array.from(
543
+ workspaces.querySelectorAll(".workspace-group[open]"), group => group.dataset.nodeId));
349
544
  const workspaceList = document.createDocumentFragment();
545
+ const unreadGroup = unreadTemplate.content.firstElementChild.cloneNode(true);
546
+ const unreadList = unreadGroup.querySelector(".workspace-group-list");
350
547
  const groups = new Map();
351
548
  for (const node of data.nodes) {
352
549
  const group = workspaceGroupTemplate.content.firstElementChild.cloneNode(true);
550
+ group.dataset.nodeId = node.id;
551
+ group.open = expanded.has(node.id);
353
552
  group.setAttribute("aria-label", node.alias || node.name);
354
- bindDeviceLabel(group.querySelector(".device-label"), node, data);
553
+ setDeviceLabel(group.querySelector(".device-label"), node);
554
+ const link = group.querySelector(".manage-link");
555
+ link.href = node.url;
556
+ link.setAttribute("aria-label", `Manage workspaces on ${node.alias || node.name}`);
355
557
  groups.set(node.id, {node, group});
356
558
  }
357
559
  for (const workspace of data.workspaces) {
@@ -375,29 +577,55 @@
375
577
  const {node, group} = groups.get(workspace.node_id);
376
578
  link.setAttribute("aria-label", `Open ${workspace.id} on ${node.alias || node.name}`);
377
579
  group.querySelector(".workspace-group-list").append(card);
580
+ if (workspace.unread) {
581
+ group.querySelector(".device-unread").hidden = false;
582
+ const unreadCard = card.cloneNode(true);
583
+ const detail = unreadCard.querySelector(".session-count");
584
+ detail.textContent = `${node.alias || node.name} · ${detail.textContent}`;
585
+ detail.title = detail.textContent;
586
+ unreadList.append(unreadCard);
587
+ }
588
+ }
589
+ if (unreadList.childElementCount) {
590
+ unreadGroup.querySelector(".count").textContent = countLabel(
591
+ unreadList.childElementCount, "workspace");
592
+ workspaceList.append(unreadGroup);
378
593
  }
379
594
  for (const {group} of groups.values()) {
380
595
  const count = group.querySelector(".workspace-group-list").childElementCount;
381
- if (!count) continue;
382
596
  group.querySelector(".count").textContent = countLabel(count, "workspace");
597
+ if (!count) {
598
+ group.querySelector(".list-head").remove();
599
+ group.querySelector(".workspace-group-list").append(
600
+ empty("No workspaces yet", "Use Manage to create a workspace on this device."));
601
+ }
383
602
  workspaceList.append(group);
384
603
  }
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."));
604
+ if (!data.nodes.length) {
605
+ workspaceList.append(empty("No workspaces yet",
606
+ "Your workspaces will appear when a device connects."));
389
607
  }
390
608
 
391
609
  const nodeList = document.createDocumentFragment();
392
610
  for (const node of data.nodes) {
393
611
  const card = deviceTemplate.content.firstElementChild.cloneNode(true);
394
- bindDeviceLabel(card.querySelector(".device-label"), node, data);
612
+ card.dataset.nodeId = node.id;
613
+ bindDeviceDrag(card, node, data);
614
+ const label = card.querySelector(".device-label");
615
+ setDeviceLabel(label, node);
616
+ const rename = card.querySelector(".device-rename");
617
+ rename.setAttribute("aria-label", `Rename device ${node.alias || node.name}`);
618
+ rename.addEventListener("click", () => editDeviceAlias(label, rename, node, data));
395
619
  const owned = data.workspaces.filter(workspace => workspace.node_id === node.id);
620
+ card.querySelector(".device-unread").hidden = !owned.some(workspace => workspace.unread);
396
621
  const sessions = owned.reduce((total, workspace) => total + workspace.session_count, 0);
397
622
  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}`);
623
+ const working = owned.filter(workspace => workspace.running).length;
624
+ for (const container of [groups.get(node.id).group, card]) {
625
+ const badge = container.querySelector(".device-working");
626
+ badge.hidden = !working;
627
+ badge.textContent = working ? `${working} working` : "";
628
+ }
401
629
  nodeList.append(card);
402
630
  }
403
631
  if (!data.nodes.length) {
@@ -405,6 +633,10 @@
405
633
  }
406
634
  workspaces.replaceChildren(workspaceList);
407
635
  nodes.replaceChildren(nodeList);
636
+ if (focusedHandle) {
637
+ const handle = nodes.querySelector(`.device[data-node-id="${CSS.escape(focusedHandle)}"] .device-drag`);
638
+ if (handle) handle.focus({preventScroll: true});
639
+ }
408
640
  document.getElementById("workspace-count").textContent = data.workspaces.length;
409
641
  document.getElementById("device-count").textContent = data.nodes.length;
410
642
  const running = data.workspaces.filter(workspace => workspace.running).length;
@@ -414,7 +646,7 @@
414
646
  async function refresh() {
415
647
  if (refreshing) return;
416
648
  refreshing = true;
417
- const revision = aliasRevision;
649
+ const revision = inventoryRevision;
418
650
  try {
419
651
  const response = await fetch("api/workspaces", {cache: "no-store"});
420
652
  if (response.status === 401) {
@@ -424,7 +656,7 @@
424
656
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
425
657
  const data = await response.json();
426
658
  const next = JSON.stringify(data);
427
- if (editingDevice === null && revision === aliasRevision && next !== signature) {
659
+ if (editingDevice === null && !reordering && revision === inventoryRevision && next !== signature) {
428
660
  render(data);
429
661
  signature = next;
430
662
  }
@@ -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
 
@@ -175,8 +176,8 @@ class Router:
175
176
  aliases = json.loads(self.aliases_path.read_text(encoding="utf-8"))
176
177
  if not isinstance(aliases, dict) or any(
177
178
  not re.fullmatch("[a-z2-7]{52}", node_id)
178
- or not alias
179
- or normalize_alias(alias) != alias
179
+ or not isinstance(alias, str)
180
+ or (alias and normalize_alias(alias) != alias)
180
181
  for node_id, alias in aliases.items()
181
182
  ):
182
183
  raise ValueError("invalid router aliases file")
@@ -200,6 +201,33 @@ class Router:
200
201
  temporary_path.unlink()
201
202
  self.aliases = aliases
202
203
 
204
+ async def reorder_devices(self, request):
205
+ if not self.browser_token or not _auth_cookie_matches(
206
+ self.browser_token, request.cookies.get("pycodex_router_auth")
207
+ ):
208
+ raise HTTPException(401, "Authentication required.")
209
+ payload = await _json_object_body(request, max_bytes=64 * 1024)
210
+ order = payload.get("order")
211
+ if (
212
+ not isinstance(order, list)
213
+ or any(
214
+ not isinstance(node_id, str)
215
+ or not re.fullmatch("[a-z2-7]{52}", node_id)
216
+ for node_id in order
217
+ )
218
+ or len(set(order)) != len(order)
219
+ ):
220
+ raise HTTPException(422, "Device order must be a list of unique device IDs.")
221
+ if set(order) != set(self.nodes):
222
+ raise HTTPException(409, "Connected devices changed. Refresh and try again.")
223
+ online_order = iter(order)
224
+ saved_order = [
225
+ next(online_order) if node_id in self.nodes else node_id
226
+ for node_id in self.aliases
227
+ ]
228
+ self._save_aliases({node_id: self.aliases[node_id] for node_id in saved_order})
229
+ return JSONResponse({"order": order})
230
+
203
231
  async def rename_device(self, request):
204
232
  if not self.browser_token or not _auth_cookie_matches(
205
233
  self.browser_token, request.cookies.get("pycodex_router_auth")
@@ -214,10 +242,7 @@ class Router:
214
242
  except ValueError as exc:
215
243
  raise HTTPException(422, str(exc))
216
244
  aliases = dict(self.aliases)
217
- if alias:
218
- aliases[node_id] = alias
219
- else:
220
- aliases.pop(node_id, None)
245
+ aliases[node_id] = alias
221
246
  self._save_aliases(aliases)
222
247
  return JSONResponse({"alias": alias})
223
248
 
@@ -265,6 +290,10 @@ class Router:
265
290
  name,
266
291
  validate_inventory(message["workspaces"]),
267
292
  )
293
+ if node_id not in self.aliases:
294
+ aliases = dict(self.aliases)
295
+ aliases[node_id] = ""
296
+ self._save_aliases(aliases)
268
297
  self.nodes[node_id] = node
269
298
  await websocket.send_text(encode({"type": "registered", "id": node_id}))
270
299
  while True:
@@ -425,7 +454,10 @@ class Router:
425
454
  async def workspaces(self, request):
426
455
  nodes = []
427
456
  workspaces = []
428
- for node in sorted(self.nodes.values(), key=lambda item: item.id):
457
+ for node_id in self.aliases:
458
+ if node_id not in self.nodes:
459
+ continue
460
+ node = self.nodes[node_id]
429
461
  base = request.scope.get("root_path", "") + "/n/" + node.id + "/"
430
462
  nodes.append({
431
463
  "id": node.id,
@@ -480,6 +512,7 @@ def create_app(
480
512
  Route("/", index),
481
513
  Route("/api/workspaces", router.workspaces),
482
514
  Route("/api/registration-key", registration_key_info),
515
+ Route("/api/nodes/order", router.reorder_devices, methods=["POST"]),
483
516
  Route("/api/nodes/{node_id}/alias", router.rename_device, methods=["POST"]),
484
517
  ]
485
518
  )
@@ -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.6"
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.6",
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