fenix-claude-plugin 0.1.2 → 0.2.0
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.
- package/.claude-plugin/plugin.json +13 -13
- package/.mcp.json +11 -11
- package/README.md +33 -40
- package/artifacts/collections.html +149 -0
- package/artifacts/fenix-workspace.html +1285 -0
- package/artifacts/oa-editor.html +210 -0
- package/artifacts/patent-editor.html +325 -0
- package/artifacts/review-workspace.html +1135 -0
- package/commands/fenix-install.md +19 -11
- package/commands/fenix-setup.md +18 -13
- package/package.json +16 -16
- package/skills/fenix-application/SKILL.md +38 -11
- package/skills/fenix-application/VERSION +1 -1
- package/skills/fenix-application/references/claim1-method-drafting.md +1 -1
- package/skills/fenix-application/references/claims.md +4 -5
- package/skills/fenix-application/references/disclosure.md +116 -10
- package/skills/fenix-application/references/editors.md +228 -39
- package/skills/fenix-application/references/figure-description.md +1 -1
- package/skills/fenix-application/references/figure-strategy.md +166 -0
- package/skills/fenix-application/references/figures-guidance.md +1 -1
- package/skills/fenix-application/references/figures.md +60 -9
- package/skills/fenix-application/references/patent-review-checklist.md +3 -1
- package/skills/fenix-application/references/patent-review.md +6 -2
- package/skills/fenix-application/references/patent-safe.md +1 -1
- package/skills/fenix-application/references/patent.md +11 -10
- package/skills/fenix-application/references/spec-guidance.md +1 -1
- package/skills/fenix-application/references/spec.md +17 -7
- package/skills/fenix-application/scripts/figure_strategy_server.py +136 -0
- package/skills/fenix-diagram/SKILL.md +54 -0
- package/skills/fenix-diagram/VERSION +1 -0
- package/skills/fenix-diagram/references/diagram-svg-guide.md +271 -0
- package/skills/fenix-office-action/SKILL.md +9 -7
- package/skills/fenix-office-action/VERSION +1 -1
- package/skills/fenix-office-action/references/oa-analysis.md +14 -4
- package/skills/fenix-office-action/references/oa-drafting.md +144 -50
- package/skills/fenix-office-action/references/oa-response.md +31 -18
- package/skills/fenix-project/SKILL.md +4 -3
- package/skills/fenix-project/VERSION +1 -1
- package/skills/fenix-project/references/create-project.md +11 -1
- package/artifacts/practice-dashboard.html +0 -469
- package/artifacts/project-overview.html +0 -481
- package/artifacts/user-dashboard.html +0 -368
- package/skills/fenix-application/CUSTOMIZE.md +0 -1
- package/skills/fenix-application/assets/claims_editor.html +0 -86
- package/skills/fenix-application/assets/figures_editor.html +0 -86
- package/skills/fenix-application/assets/spec_editor.html +0 -86
- package/skills/fenix-office-action/CUSTOMIZE.md +0 -1
- package/skills/fenix-project/CUSTOMIZE.md +0 -1
|
@@ -0,0 +1,1285 @@
|
|
|
1
|
+
<!DOCTYPE html><script type="application/json" id="cowork-artifact-meta">
|
|
2
|
+
{
|
|
3
|
+
"name": "Fenix",
|
|
4
|
+
"schemaVersion": 1,
|
|
5
|
+
"description": "The Fenix workspace wrapper. Knows nothing about Fenix except two tool names. Pages arrive as full HTML via read {resource:\"page\"}, fill the frame, and make every call through the wrapper's bridge.",
|
|
6
|
+
"mcpTools": [
|
|
7
|
+
"mcp__21ddcdf7-4f7b-45f7-ad0e-35afe7c1c96a__read",
|
|
8
|
+
"mcp__21ddcdf7-4f7b-45f7-ad0e-35afe7c1c96a__write"
|
|
9
|
+
],
|
|
10
|
+
"mcpServerNames": [
|
|
11
|
+
"Fenix",
|
|
12
|
+
"Fenix"
|
|
13
|
+
]
|
|
14
|
+
}
|
|
15
|
+
</script>
|
|
16
|
+
<script type="application/json" id="fenix-install-manifest">
|
|
17
|
+
{
|
|
18
|
+
"server": "Fenix",
|
|
19
|
+
"tools": [
|
|
20
|
+
"read",
|
|
21
|
+
"write"
|
|
22
|
+
],
|
|
23
|
+
"note": "Register every listed tool fully-qualified at install. This artifact calls all of them, and a partial grant fails at the first missing one."
|
|
24
|
+
}
|
|
25
|
+
</script>
|
|
26
|
+
<html lang="en">
|
|
27
|
+
<head>
|
|
28
|
+
<meta charset="utf-8">
|
|
29
|
+
<title>Fenix</title>
|
|
30
|
+
<style>
|
|
31
|
+
* { box-sizing: border-box; }
|
|
32
|
+
html, body { height: 100%; }
|
|
33
|
+
/* No fill on the body, and none on the iframe either: a page carries its own
|
|
34
|
+
background, and the status line carries its own, so every region the
|
|
35
|
+
workspace itself owns is transparent and shows the HOST's surface rather
|
|
36
|
+
than a black band of our own. Before a page loads there is nothing of ours
|
|
37
|
+
to see — no dark flash — and the floating nav icons sit over the host too. */
|
|
38
|
+
body { margin:0; background:transparent; color:hsl(220 10% 92%);
|
|
39
|
+
font-family:-apple-system,"Segoe UI",system-ui,sans-serif;
|
|
40
|
+
display:grid; grid-template-rows:1fr 24px; }
|
|
41
|
+
|
|
42
|
+
/* content — the page owns everything above the line. There is no nav STRIP
|
|
43
|
+
any more: the category icons float in the page's top-right corner (see
|
|
44
|
+
#navCats) rather than in a band of their own, so the page reaches the very
|
|
45
|
+
top and the workspace shows one less line of its own chrome. #main is the
|
|
46
|
+
positioning context for that overlay. */
|
|
47
|
+
#main { min-height:0; display:flex; position:relative; }
|
|
48
|
+
iframe { flex:1; border:0; background:transparent; }
|
|
49
|
+
|
|
50
|
+
/* THE ONE LINE — and it should be easy to forget.
|
|
51
|
+
It is chrome under someone else's page, so it sits at the SAME lightness as
|
|
52
|
+
the body rather than a shade above it: a lighter strip reads as a panel and
|
|
53
|
+
draws the eye all day for information that is almost always "fine". Nothing
|
|
54
|
+
here carries colour while things are normal. Colour is reserved for the
|
|
55
|
+
states that are not — a warn/bad light, an error badge, an offered move —
|
|
56
|
+
so that when something IS coloured, it means something. Everything else
|
|
57
|
+
comes up to legibility on hover, when you have gone looking for it. */
|
|
58
|
+
#foot { display:flex; align-items:center; gap:10px; padding:0 12px;
|
|
59
|
+
background:hsl(220 10% 6%); border-top:1px solid hsl(220 8% 11%);
|
|
60
|
+
font-size:11px; color:hsl(220 4% 34%); }
|
|
61
|
+
#foot .led { width:6px; height:6px; border-radius:50%; background:hsl(220 5% 24%); flex:0 0 auto; }
|
|
62
|
+
/* Healthy is deliberately dim: a bright green light every second of every day
|
|
63
|
+
trains you to ignore the light, which is the one thing it must not do. */
|
|
64
|
+
#foot .led.ok { background:hsl(142 22% 32%); }
|
|
65
|
+
#foot .led.warn { background:hsl(45 90% 60%); }
|
|
66
|
+
#foot .led.bad { background:hsl(0 85% 68%); }
|
|
67
|
+
#foot .mono { font-family:ui-monospace,Consolas,monospace; }
|
|
68
|
+
#ctxSeg { color:hsl(220 5% 44%); cursor:pointer; max-width:260px; white-space:nowrap;
|
|
69
|
+
overflow:hidden; text-overflow:ellipsis; }
|
|
70
|
+
#ctxSeg:hover { color:hsl(220 10% 82%); }
|
|
71
|
+
/* the context URL — the only thing on the line that says where this page IS.
|
|
72
|
+
direction:rtl truncates from the LEFT, keeping the distinguishing tail.
|
|
73
|
+
Grey until hovered: it is a long string and a full-time blue link in the
|
|
74
|
+
corner of the eye is exactly the conspicuousness this bar should not have. */
|
|
75
|
+
#ctxUrl { color:hsl(220 4% 34%); text-decoration:none; max-width:340px; white-space:nowrap;
|
|
76
|
+
overflow:hidden; text-overflow:ellipsis; direction:rtl; text-align:left; }
|
|
77
|
+
#ctxUrl:hover { color:hsl(205 50% 60%); text-decoration:underline; }
|
|
78
|
+
#ctxUrl:empty { display:none; }
|
|
79
|
+
/* The category icons: tool / collections / dashboards.
|
|
80
|
+
They FLOAT in the page's top-right corner rather than sitting in a strip of
|
|
81
|
+
their own — the wrapper still owns them (same three destinations from every
|
|
82
|
+
page), but they read as part of the page instead of a band above it.
|
|
83
|
+
The group NEVER changes width and the icons never move — the one you are
|
|
84
|
+
already in stays in place, highlighted and inert, rather than disappearing.
|
|
85
|
+
A group that reflows as you navigate is one you cannot aim at without
|
|
86
|
+
looking, which for a control used this often is most of its value.
|
|
87
|
+
|
|
88
|
+
A frosted translucent pill (blur, no solid fill) carries them: the icons
|
|
89
|
+
sit over arbitrary page content that may be light OR dark, and a blur keeps
|
|
90
|
+
them legible on either without painting a block of colour. There is NO dark
|
|
91
|
+
fill anywhere here — the old active state used a near-black square, which
|
|
92
|
+
over the now-light host surface read as exactly the black box we were told
|
|
93
|
+
was too much. Selection is a light translucent highlight instead. */
|
|
94
|
+
#navCats { position:absolute; top:8px; right:10px; z-index:6;
|
|
95
|
+
display:flex; align-items:center; gap:2px; padding:3px;
|
|
96
|
+
border-radius:9px; background:hsl(220 14% 55% / .10);
|
|
97
|
+
-webkit-backdrop-filter:blur(10px) saturate(1.25);
|
|
98
|
+
backdrop-filter:blur(10px) saturate(1.25); }
|
|
99
|
+
#navCats:empty { display:none; }
|
|
100
|
+
#navCats button { background:none; border:0; border-radius:6px;
|
|
101
|
+
padding:2px 6px; font-size:15px; line-height:1; cursor:pointer;
|
|
102
|
+
filter:grayscale(.4) opacity(.55);
|
|
103
|
+
transition:filter .12s ease, background .12s ease; }
|
|
104
|
+
#navCats button:hover:not(:disabled) { filter:none; background:hsl(220 16% 62% / .20); }
|
|
105
|
+
/* Active: full colour and a light highlight. Disabled too, so it cannot be
|
|
106
|
+
clicked and reads as "you are here" to a screen reader as well. */
|
|
107
|
+
#navCats button.active { filter:none; background:hsl(220 18% 66% / .26); cursor:default; }
|
|
108
|
+
/* Nothing to open — no matter bound, so the tool icon has no destination.
|
|
109
|
+
Shown rather than hidden, so the group keeps its shape. */
|
|
110
|
+
#navCats button.empty { cursor:not-allowed; filter:grayscale(1) opacity(.25); }
|
|
111
|
+
/* auto-switch toggle + the manual "go" it degrades to when switched off */
|
|
112
|
+
#followWrap { display:flex; align-items:center; gap:4px; cursor:pointer; user-select:none;
|
|
113
|
+
color:hsl(220 4% 34%); flex:0 0 auto; }
|
|
114
|
+
#followWrap:hover { color:hsl(220 10% 82%); }
|
|
115
|
+
#followWrap input { margin:0; cursor:pointer; width:11px; height:11px;
|
|
116
|
+
accent-color:hsl(142 22% 38%); }
|
|
117
|
+
/* Stays visible: this one is an OFFER, and an offer nobody notices is a bug.
|
|
118
|
+
Toned down from the original amber, not switched off. */
|
|
119
|
+
#ctxGo { background:hsl(45 70% 55% / .10); border:1px solid hsl(45 70% 55% / .35);
|
|
120
|
+
color:hsl(45 55% 62%); border-radius:5px; padding:1px 8px; font-size:11px;
|
|
121
|
+
cursor:pointer; flex:0 0 auto; }
|
|
122
|
+
#ctxGo:hover { background:hsl(45 80% 58% / .22); color:hsl(45 85% 72%); }
|
|
123
|
+
#stMsg { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; min-width:0; }
|
|
124
|
+
#foot .spacer { flex:1; }
|
|
125
|
+
#diagBtn { background:none; border:none; color:hsl(220 4% 30%); font-size:11px;
|
|
126
|
+
cursor:pointer; padding:0; }
|
|
127
|
+
#diagBtn:hover { color:hsl(220 10% 82%); }
|
|
128
|
+
|
|
129
|
+
/* diagnostics drawer (opens from the line, on demand) */
|
|
130
|
+
#diag { position:fixed; left:0; right:0; bottom:24px; height:0; overflow:hidden;
|
|
131
|
+
background:hsl(220 9% 9%); border-top:1px solid hsl(220 8% 16%);
|
|
132
|
+
transition:height .15s ease; display:flex; flex-direction:column; z-index:20; }
|
|
133
|
+
#diag.open { height:250px; }
|
|
134
|
+
#diag .bar { display:flex; gap:8px; align-items:center; padding:8px 12px;
|
|
135
|
+
border-bottom:1px solid hsl(220 8% 16%); flex:0 0 auto; }
|
|
136
|
+
#diag button { background:hsl(220 8% 12%); color:hsl(220 10% 92%);
|
|
137
|
+
border:1px solid hsl(220 8% 16%); border-radius:6px; padding:5px 11px;
|
|
138
|
+
font-size:11.5px; cursor:pointer; }
|
|
139
|
+
#diag button:hover { background:hsl(220 8% 15%); }
|
|
140
|
+
#diag .stats { font:11px ui-monospace,Consolas,monospace; color:hsl(220 5% 45%);
|
|
141
|
+
margin-left:auto; }
|
|
142
|
+
#log { flex:1; overflow-y:auto; padding:8px 12px;
|
|
143
|
+
font:11px ui-monospace,Consolas,monospace; color:hsl(220 6% 62%); }
|
|
144
|
+
#log .ok { color:hsl(142 60% 45%); }
|
|
145
|
+
#log .err { color:hsl(0 85% 68%); }
|
|
146
|
+
#log .cache { color:hsl(200 70% 60%); }
|
|
147
|
+
|
|
148
|
+
/* THE ERROR PANEL — it lives in the drawer, not over the page.
|
|
149
|
+
This was a full-screen modal, and it was wrong twice over. It covered the
|
|
150
|
+
page it was complaining about (so you could not see the thing that failed,
|
|
151
|
+
or keep working around it), and it was hard to get rid of — a retrying
|
|
152
|
+
failure reopened it as fast as you dismissed it. An artifact is a PANE in
|
|
153
|
+
someone else's window; nothing in it has the standing to take the whole
|
|
154
|
+
screen away.
|
|
155
|
+
So the failure lands where the rest of this bar's information lands: the
|
|
156
|
+
drawer, expanded. The line keeps a red badge that says an error is there and
|
|
157
|
+
opens straight to it, which is what "on the screen, not folded into one
|
|
158
|
+
status word" actually required — the modal was never the point. */
|
|
159
|
+
#diagErr { flex:0 0 auto; max-height:150px; overflow-y:auto; padding:10px 12px;
|
|
160
|
+
border-bottom:1px solid hsl(220 8% 16%); background:hsl(0 30% 8%);
|
|
161
|
+
font-size:12px; line-height:1.5; color:hsl(220 8% 78%); display:none; }
|
|
162
|
+
#diagErr.on { display:block; }
|
|
163
|
+
#diagErr h2 { margin:0 0 6px; font-size:12.5px; color:hsl(0 80% 74%); }
|
|
164
|
+
#diagErr .msg { white-space:pre-wrap; word-break:break-word;
|
|
165
|
+
font:11.5px ui-monospace,Consolas,monospace;
|
|
166
|
+
background:hsl(220 10% 6%); border:1px solid hsl(220 8% 16%);
|
|
167
|
+
border-radius:6px; padding:8px 10px; color:hsl(0 75% 76%); }
|
|
168
|
+
/* Repeats collapse into a count. A retrying failure otherwise writes the same
|
|
169
|
+
paragraph a hundred times and buries the one that is different. */
|
|
170
|
+
#diagErr .rep { color:hsl(0 50% 62%); font-size:11px; margin-left:6px; }
|
|
171
|
+
#diagErr h3 { font-size:11.5px; color:hsl(220 10% 92%); margin:10px 0 4px; }
|
|
172
|
+
#diagErr ul { margin:0; padding-left:18px; font:11.5px ui-monospace,Consolas,monospace; }
|
|
173
|
+
#diagErr li.no { color:hsl(0 80% 74%); }
|
|
174
|
+
#diagErr li.yes { color:hsl(142 50% 60%); }
|
|
175
|
+
#diagErr p { margin:5px 0; }
|
|
176
|
+
#diagErr .dim { color:hsl(220 5% 52%); }
|
|
177
|
+
#diagErr details { margin-top:8px; }
|
|
178
|
+
#diagErr summary { cursor:pointer; color:hsl(220 6% 58%); font-size:11.5px; }
|
|
179
|
+
#diagErr .acts { display:flex; gap:8px; margin-top:9px; }
|
|
180
|
+
#diagErr button { background:hsl(220 8% 12%); color:hsl(220 10% 92%);
|
|
181
|
+
border:1px solid hsl(220 8% 16%); border-radius:6px; padding:4px 10px;
|
|
182
|
+
font-size:11.5px; cursor:pointer; }
|
|
183
|
+
#diagErr button:hover { background:hsl(220 8% 15%); }
|
|
184
|
+
#errBadge { background:none; border:1px solid hsl(0 60% 34%); border-radius:5px;
|
|
185
|
+
background:hsl(0 60% 34% / .14); color:hsl(0 80% 74%); font-size:11px;
|
|
186
|
+
cursor:pointer; padding:1px 8px; }
|
|
187
|
+
#errBadge:hover { background:hsl(0 65% 40% / .26); color:hsl(0 85% 82%); }
|
|
188
|
+
</style>
|
|
189
|
+
</head>
|
|
190
|
+
<body>
|
|
191
|
+
|
|
192
|
+
<!-- The page fills #main; #navCats floats over its top-right corner. navCats is
|
|
193
|
+
the FIRST child so it is never wiped when a page mounts — mountPage swaps
|
|
194
|
+
only the <iframe>, leaving its siblings in place. -->
|
|
195
|
+
<div id="main">
|
|
196
|
+
<span id="navCats" role="navigation" aria-label="Workspace"></span>
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
<div id="foot">
|
|
200
|
+
<span class="led" id="led"></span>
|
|
201
|
+
<span id="stConnT" class="mono">…</span>
|
|
202
|
+
<span id="ctxSeg" class="mono" title="Current context — click to refresh">—</span>
|
|
203
|
+
<label id="followWrap" title="Auto-switch: when chat or the website moves to another matter, open it here automatically. Turn off to stay put — you will be offered the move instead of taken there, which is what you want mid-edit."><input type="checkbox" id="follow"><span>auto-switch</span></label>
|
|
204
|
+
<button id="ctxGo" style="display:none">open →</button>
|
|
205
|
+
<!-- The URL comes last of the context group: it is the longest item and the
|
|
206
|
+
only one that truncates, so anything after it would sit at a position that
|
|
207
|
+
shifts with the address. The control keeps a fixed home. -->
|
|
208
|
+
<a id="ctxUrl" class="mono" target="_blank" rel="noreferrer"></a>
|
|
209
|
+
<span id="stCacheT" class="mono"></span>
|
|
210
|
+
<span id="stMsg"></span>
|
|
211
|
+
<span class="spacer"></span>
|
|
212
|
+
<button id="errBadge" style="display:none">show error</button>
|
|
213
|
+
<button id="diagBtn">⋯</button>
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
<div id="diag">
|
|
217
|
+
<div class="bar">
|
|
218
|
+
<button id="btnReload">Reload page</button>
|
|
219
|
+
<button id="btnClear">Clear page cache</button>
|
|
220
|
+
<button id="btnCopy">Copy details</button>
|
|
221
|
+
<span class="stats" id="diagStats"></span>
|
|
222
|
+
</div>
|
|
223
|
+
<!-- Above the log, because it is the thing you opened the drawer for. Not a
|
|
224
|
+
dialog: it steals no focus and blocks nothing. -->
|
|
225
|
+
<div id="diagErr" role="status" aria-live="polite">
|
|
226
|
+
<h2><span id="errTitle"></span><span class="rep" id="errRepeat"></span></h2>
|
|
227
|
+
<div class="msg" id="errMsg"></div>
|
|
228
|
+
<div id="errGrantWrap">
|
|
229
|
+
<h3>Tool authorization</h3>
|
|
230
|
+
<div id="errGrant"></div>
|
|
231
|
+
</div>
|
|
232
|
+
<!-- Collapsed. This explains the single most common install failure, but it
|
|
233
|
+
is four paragraphs, and on a server-side page error it is noise. Opened
|
|
234
|
+
automatically when the grant is the actual problem. -->
|
|
235
|
+
<details id="errInstall">
|
|
236
|
+
<summary>Where you install this from decides whether it works</summary>
|
|
237
|
+
<p>
|
|
238
|
+
<strong>💻 Local session (laptop icon)</strong> — a conversation running on this
|
|
239
|
+
computer. Installing from there registers the tool grant, and the artifact can call
|
|
240
|
+
Fenix. This is the only install that works.
|
|
241
|
+
</p>
|
|
242
|
+
<p>
|
|
243
|
+
<strong>☁️ Cloud session (cloud icon)</strong> — a conversation running on Anthropic's
|
|
244
|
+
servers. It <em>can</em> create the artifact, and the artifact will open and render —
|
|
245
|
+
but the grant is stripped in transit, by design: a remote session must not be able to
|
|
246
|
+
hand out access to tools on your machine. The result is a page that looks installed and
|
|
247
|
+
can never load data.
|
|
248
|
+
</p>
|
|
249
|
+
<p class="dim">
|
|
250
|
+
So reinstalling from the same cloud conversation produces the identical failure. Open a
|
|
251
|
+
local conversation — the one with the laptop icon — and install it there. There is no
|
|
252
|
+
permission prompt to find: with no grant, there is nothing to approve.
|
|
253
|
+
</p>
|
|
254
|
+
</details>
|
|
255
|
+
<div class="acts">
|
|
256
|
+
<button id="errCopy">Copy error</button>
|
|
257
|
+
<button id="errReload">Retry page</button>
|
|
258
|
+
<button id="errClose" style="margin-left:auto">Clear</button>
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
<div id="log"></div>
|
|
262
|
+
</div>
|
|
263
|
+
|
|
264
|
+
<script>
|
|
265
|
+
(function () {
|
|
266
|
+
'use strict';
|
|
267
|
+
var $ = function (id) { return document.getElementById(id); };
|
|
268
|
+
var ART_ID = 'fenix-workspace';
|
|
269
|
+
var VERSION = 'v13';
|
|
270
|
+
var CACHE_NS = 'fx.pagecache.';
|
|
271
|
+
var CACHE_FMT = 1;
|
|
272
|
+
// The context poll. 3s, because the point of following context is that it
|
|
273
|
+
// feels immediate — a workspace that lags 20s behind the conversation is one
|
|
274
|
+
// you stop trusting and start refreshing by hand.
|
|
275
|
+
//
|
|
276
|
+
// Affordable because of what the poll actually SENDS: `haveRev`, the revision
|
|
277
|
+
// last seen. An unchanged context comes back {notModified:true, rev} — no
|
|
278
|
+
// payload, and on the server no query beyond the user-document read that
|
|
279
|
+
// authentication already had to do. See get_context in mcp.ts.
|
|
280
|
+
//
|
|
281
|
+
// The TTL is now SHORTER than the interval on purpose. It used to be longer
|
|
282
|
+
// (15s TTL against a 20s poll), which guaranteed every scheduled poll missed
|
|
283
|
+
// its own cache; the cache existed only to absorb the page's own reads. It
|
|
284
|
+
// still does that, and now never stands between the poll and a change.
|
|
285
|
+
var CTX_TTL_MS = 2000;
|
|
286
|
+
var CTX_POLL_MS = 3000;
|
|
287
|
+
/** Backs off to this while the workspace is hidden — nobody is watching. */
|
|
288
|
+
var CTX_POLL_IDLE_MS = 30000;
|
|
289
|
+
/** localStorage key: does this workspace follow the context, or stay put? */
|
|
290
|
+
var FOLLOW_KEY = 'fx.followContext';
|
|
291
|
+
var details = { kind: null, rpc: [], errors: [] };
|
|
292
|
+
var currentPage = null; // {name, version}
|
|
293
|
+
|
|
294
|
+
// ---- meta / manifest / grant -------------------------------------------
|
|
295
|
+
function readJsonBlock(id) {
|
|
296
|
+
try { var el = document.getElementById(id); return el ? JSON.parse(el.textContent) : null; }
|
|
297
|
+
catch (e) { return null; }
|
|
298
|
+
}
|
|
299
|
+
var manifest = readJsonBlock('fenix-install-manifest') || { tools: [] };
|
|
300
|
+
var meta = readJsonBlock('cowork-artifact-meta') || {};
|
|
301
|
+
var grantedFq = meta.mcpTools || [];
|
|
302
|
+
function bare(n) {
|
|
303
|
+
n = String(n);
|
|
304
|
+
if (n.indexOf('mcp__') === 0) { var i = n.lastIndexOf('__'); return n.slice(i + 2); }
|
|
305
|
+
return n;
|
|
306
|
+
}
|
|
307
|
+
var grantedBare = {};
|
|
308
|
+
for (var i = 0; i < grantedFq.length; i++) grantedBare[bare(grantedFq[i])] = true;
|
|
309
|
+
|
|
310
|
+
var PREFIX = '';
|
|
311
|
+
if (grantedFq.length) {
|
|
312
|
+
var f0 = grantedFq[0];
|
|
313
|
+
PREFIX = f0.slice(0, f0.lastIndexOf('__') + 2);
|
|
314
|
+
try { localStorage.setItem('fenix.mcp.prefix', PREFIX); } catch (e) {}
|
|
315
|
+
} else {
|
|
316
|
+
try { PREFIX = localStorage.getItem('fenix.mcp.prefix') || ''; } catch (e) {}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ---- status (the one line) ---------------------------------------------
|
|
320
|
+
var LIVE = !!(window.cowork && typeof window.cowork.callMcpTool === 'function');
|
|
321
|
+
function setConn(cls, text, msg) {
|
|
322
|
+
$('led').className = 'led ' + cls;
|
|
323
|
+
$('stConnT').textContent = text;
|
|
324
|
+
$('stMsg').textContent = msg || '';
|
|
325
|
+
}
|
|
326
|
+
// Deferred so the modal opens AFTER the whole script has run — selfCheck sits
|
|
327
|
+
// above the modal's own section, and its result is the single most common
|
|
328
|
+
// reason this page never loads, so it must be shown, not just summarised.
|
|
329
|
+
(function selfCheck() {
|
|
330
|
+
if (!LIVE) {
|
|
331
|
+
details.kind = 'no-bridge';
|
|
332
|
+
setConn('bad', 'offline', 'No connection to Fenix from this surface — open the installed copy from the Cowork sidebar.');
|
|
333
|
+
setTimeout(function () {
|
|
334
|
+
showErr('No connection to Fenix',
|
|
335
|
+
'This surface has no MCP bridge, so no tool call can be made from it at all.\n\n' +
|
|
336
|
+
'This is what a file preview looks like. Open the INSTALLED copy from the Cowork ' +
|
|
337
|
+
'sidebar rather than the generated file.', true);
|
|
338
|
+
}, 0);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
var missing = missingTools();
|
|
342
|
+
if (grantedFq.length === 0) {
|
|
343
|
+
details.kind = 'no-access';
|
|
344
|
+
setConn('bad', 'no access', 'No tool grant on this copy. Ask a desktop conversation to reinstall it with its Fenix permissions.');
|
|
345
|
+
setTimeout(function () {
|
|
346
|
+
showErr('This copy has no tool grant',
|
|
347
|
+
'The installed artifact registered zero tools, so every call fails before it reaches ' +
|
|
348
|
+
'Fenix.\n\nThe overwhelmingly common cause is below: it was installed from a cloud ' +
|
|
349
|
+
'session, where the grant is stripped in transit.', true);
|
|
350
|
+
}, 0);
|
|
351
|
+
} else if (missing.length) {
|
|
352
|
+
details.kind = 'partial-access';
|
|
353
|
+
setConn('warn', grantedFq.length + '/' + manifest.tools.length, 'Missing: ' + missing.join(', ') + '. Ask a desktop conversation to reinstall with all Fenix permissions.');
|
|
354
|
+
setTimeout(function () {
|
|
355
|
+
showErr('This copy is missing tool permissions',
|
|
356
|
+
'Granted ' + grantedFq.length + ' of ' + manifest.tools.length + ' tools. Missing: ' +
|
|
357
|
+
missing.join(', ') + '.\n\nA partial grant fails at the first missing tool, so pages ' +
|
|
358
|
+
'will load partway and then stop.', true);
|
|
359
|
+
}, 0);
|
|
360
|
+
} else {
|
|
361
|
+
details.kind = 'ready';
|
|
362
|
+
setConn('ok', 'fenix', '');
|
|
363
|
+
}
|
|
364
|
+
})();
|
|
365
|
+
|
|
366
|
+
// ---- the error panel ----------------------------------------------------
|
|
367
|
+
//
|
|
368
|
+
// An error once reached the user as one word on the status line ("page error")
|
|
369
|
+
// plus a log line behind a ⋯ drawer nobody opens. That is not enough to act on
|
|
370
|
+
// and not enough to relay: failures arrived in chat as "it isn't working". The
|
|
371
|
+
// answer to that was a full-screen modal, and it overshot — it covered the
|
|
372
|
+
// page that had failed and fought you when you tried to close it.
|
|
373
|
+
//
|
|
374
|
+
// What was actually needed is that the failure be VISIBLE and ADDRESSABLE, not
|
|
375
|
+
// that it be blocking. So: the line always carries a red badge while an error
|
|
376
|
+
// stands, and the badge opens the drawer with the full text, the grant list and
|
|
377
|
+
// a copy button. The drawer opens BY ITSELF only when the artifact can never
|
|
378
|
+
// work at all (no bridge, no grant, partial grant) — those are not events to
|
|
379
|
+
// acknowledge, they are the state of the thing, and no page is going to render
|
|
380
|
+
// behind them anyway. A page-level error stays an offer to look.
|
|
381
|
+
var lastError = null;
|
|
382
|
+
var errRepeats = 0;
|
|
383
|
+
/** Identity of the displayed error, so a retry loop increments instead of shouting. */
|
|
384
|
+
var errKey = null;
|
|
385
|
+
function missingTools() {
|
|
386
|
+
return manifest.tools.filter(function (t) { return !grantedBare[t]; });
|
|
387
|
+
}
|
|
388
|
+
function renderGrant() {
|
|
389
|
+
var el = $('errGrant');
|
|
390
|
+
el.innerHTML = '';
|
|
391
|
+
if (!manifest.tools.length && !grantedFq.length) {
|
|
392
|
+
var p = document.createElement('p');
|
|
393
|
+
p.className = 'dim';
|
|
394
|
+
p.textContent = 'This copy declares no tools at all — it is not an installed artifact.';
|
|
395
|
+
el.appendChild(p);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
var ul = document.createElement('ul');
|
|
399
|
+
manifest.tools.forEach(function (t) {
|
|
400
|
+
var ok = !!grantedBare[t];
|
|
401
|
+
var li = document.createElement('li');
|
|
402
|
+
li.className = ok ? 'yes' : 'no';
|
|
403
|
+
li.textContent = (ok ? '✓ ' : '✗ ') + t + (ok ? '' : ' — not granted');
|
|
404
|
+
ul.appendChild(li);
|
|
405
|
+
});
|
|
406
|
+
el.appendChild(ul);
|
|
407
|
+
var note = document.createElement('p');
|
|
408
|
+
if (!grantedFq.length) {
|
|
409
|
+
note.className = 'no';
|
|
410
|
+
note.textContent = 'No tools are granted, so nothing on this page can reach Fenix. ' +
|
|
411
|
+
'This is not a prompt someone dismissed — there is nothing to approve.';
|
|
412
|
+
} else if (missingTools().length) {
|
|
413
|
+
note.className = 'no';
|
|
414
|
+
note.textContent = 'A partial grant fails at the first missing tool. Reinstall with all of them.';
|
|
415
|
+
} else {
|
|
416
|
+
note.className = 'dim';
|
|
417
|
+
note.textContent = 'The grant is complete, so this is a server-side error rather than a permission problem.';
|
|
418
|
+
}
|
|
419
|
+
el.appendChild(note);
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Show an error in the drawer.
|
|
423
|
+
*
|
|
424
|
+
* `fatal` means the artifact cannot work at all — the drawer opens itself.
|
|
425
|
+
* Everything else badges the line and waits to be asked.
|
|
426
|
+
*/
|
|
427
|
+
function showErr(title, text, fatal) {
|
|
428
|
+
// ESCAPE, not a literal control character. This was a raw NUL byte, which
|
|
429
|
+
// JavaScript accepts happily -- and which made this whole file register as
|
|
430
|
+
// BINARY: grep found nothing in it, diffs went opaque, and a \u0000 shipped
|
|
431
|
+
// inside artifacts.generated.json. A separator that cannot occur in a title
|
|
432
|
+
// or a server message is the right idea; writing it as an escape keeps the
|
|
433
|
+
// source itself plain text.
|
|
434
|
+
var key = title + ' \u0001 ' + text;
|
|
435
|
+
errRepeats = (key === errKey) ? errRepeats + 1 : 0;
|
|
436
|
+
errKey = key;
|
|
437
|
+
lastError = { title: title, text: text, at: new Date().toISOString(),
|
|
438
|
+
repeats: errRepeats };
|
|
439
|
+
$('errTitle').textContent = title;
|
|
440
|
+
$('errMsg').textContent = text;
|
|
441
|
+
$('errRepeat').textContent = errRepeats ? '(×' + (errRepeats + 1) + ')' : '';
|
|
442
|
+
renderGrant();
|
|
443
|
+
// The install essay is only the answer when the grant is the problem.
|
|
444
|
+
var grantProblem = !grantedFq.length || missingTools().length > 0;
|
|
445
|
+
$('errInstall').open = grantProblem;
|
|
446
|
+
$('errGrantWrap').style.display = grantProblem || !LIVE ? '' : 'none';
|
|
447
|
+
$('diagErr').classList.add('on');
|
|
448
|
+
$('errBadge').style.display = '';
|
|
449
|
+
if (fatal) $('diag').classList.add('open');
|
|
450
|
+
}
|
|
451
|
+
function clearErr() {
|
|
452
|
+
errKey = null; errRepeats = 0;
|
|
453
|
+
$('diagErr').classList.remove('on');
|
|
454
|
+
$('errBadge').style.display = 'none';
|
|
455
|
+
}
|
|
456
|
+
$('errClose').addEventListener('click', clearErr);
|
|
457
|
+
$('errBadge').addEventListener('click', function () {
|
|
458
|
+
$('diag').classList.add('open');
|
|
459
|
+
$('diagErr').scrollTop = 0;
|
|
460
|
+
});
|
|
461
|
+
$('errCopy').addEventListener('click', function () {
|
|
462
|
+
copyTo($('errCopy'), JSON.stringify({ error: lastError, report: buildReport() }, null, 2));
|
|
463
|
+
});
|
|
464
|
+
$('errReload').addEventListener('click', function () {
|
|
465
|
+
// An explicit retry is consent to try the unreachable address again.
|
|
466
|
+
undrawn = {};
|
|
467
|
+
openPage(currentPage ? (currentPage.address || currentPage.name) : null);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
// ---- log ----------------------------------------------------------------
|
|
471
|
+
function log(msg, cls) {
|
|
472
|
+
var d = document.createElement('div');
|
|
473
|
+
if (cls) d.className = cls;
|
|
474
|
+
d.textContent = msg;
|
|
475
|
+
$('log').appendChild(d);
|
|
476
|
+
if ($('log').childNodes.length > 400) $('log').removeChild($('log').firstChild);
|
|
477
|
+
$('log').scrollTop = 1e9;
|
|
478
|
+
}
|
|
479
|
+
$('diagBtn').addEventListener('click', function () { $('diag').classList.toggle('open'); });
|
|
480
|
+
|
|
481
|
+
// ---- page cache ---------------------------------------------------------
|
|
482
|
+
/**
|
|
483
|
+
* THE SHARED EDITOR RUNTIME — Tiptap/ProseMirror + Preact.
|
|
484
|
+
*
|
|
485
|
+
* Both editors are built WITHOUT it and read it from a `__FXV` global instead,
|
|
486
|
+
* so it ships once rather than ~419 KB inside each. This wrapper is what
|
|
487
|
+
* provides it: fetched by name through the same page channel, cached PINNED, and
|
|
488
|
+
* spliced into an editor's frame ahead of the editor's own script.
|
|
489
|
+
*
|
|
490
|
+
* Held in memory for the session as well as in the cache, because every editor
|
|
491
|
+
* open needs it and the frame is rebuilt each time. The version is verified ONCE
|
|
492
|
+
* per session (a cheap notModified poll); a Tiptap upgrade lands on the next
|
|
493
|
+
* load, which is soon enough for something that changes a few times a year.
|
|
494
|
+
*/
|
|
495
|
+
var VENDOR_NAME = 'tiptap-vendor';
|
|
496
|
+
var vendorJs = null; // the runtime source for this session, once resolved
|
|
497
|
+
var vendorChecked = false; // version verified against the server this session
|
|
498
|
+
|
|
499
|
+
/** Pages that need the shared runtime: the editors, which are all under tools/. */
|
|
500
|
+
function needsVendor(name) { return String(name || '').indexOf('tools/') === 0; }
|
|
501
|
+
|
|
502
|
+
function cacheKey(name) { return CACHE_NS + 'page.' + name; }
|
|
503
|
+
|
|
504
|
+
// WHAT SURVIVES EVICTION when localStorage fills. Higher stays.
|
|
505
|
+
//
|
|
506
|
+
// The editors are the biggest pages by far AND the ones you return to across a
|
|
507
|
+
// session, so a dashboard you glanced at once must not push out the editor you
|
|
508
|
+
// keep opening. Two tiers do that: editors sit above every other page. A future
|
|
509
|
+
// PINNED tier is reserved for a shared vendor bundle (the Tiptap runtime split),
|
|
510
|
+
// which must never be evicted — re-fetching it would defeat the whole point —
|
|
511
|
+
// so it is named here even though nothing writes it yet.
|
|
512
|
+
var CACHE_PRI = { pinned: 3, editor: 2, page: 1 };
|
|
513
|
+
function cachePriority(name) {
|
|
514
|
+
// The shared editor runtime is PINNED: both editors reference it instead of
|
|
515
|
+
// carrying their own copy, so evicting it would mean re-downloading half a
|
|
516
|
+
// megabyte the next time either one opens — the exact cost the split removed.
|
|
517
|
+
if (name === VENDOR_NAME) return CACHE_PRI.pinned;
|
|
518
|
+
return String(name || '').indexOf('tools/') === 0 ? CACHE_PRI.editor : CACHE_PRI.page;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function cacheGet(name) {
|
|
522
|
+
try {
|
|
523
|
+
var rec = JSON.parse(localStorage.getItem(cacheKey(name)) || 'null');
|
|
524
|
+
if (!rec || rec.fmt !== CACHE_FMT || typeof rec.html !== 'string') return null;
|
|
525
|
+
// READING A PAGE IS USING IT. Bump its access time so eviction is LRU by
|
|
526
|
+
// USE, not by write. The old logic ordered by savedAt, which a cache HIT
|
|
527
|
+
// never touched — so an editor opened fifty times but fetched once looked
|
|
528
|
+
// older than a dashboard fetched a moment ago, and got evicted first. This
|
|
529
|
+
// is the whole bug. Best-effort and same-size (only usedAt changes on an
|
|
530
|
+
// existing key), so it cannot exceed quota; a failed write just leaves the
|
|
531
|
+
// previous time, which is harmless.
|
|
532
|
+
rec.usedAt = Date.now();
|
|
533
|
+
try { localStorage.setItem(cacheKey(name), JSON.stringify(rec)); } catch (e2) {}
|
|
534
|
+
return rec;
|
|
535
|
+
} catch (e) { return null; }
|
|
536
|
+
}
|
|
537
|
+
function ownKeys() {
|
|
538
|
+
var keys = [];
|
|
539
|
+
try {
|
|
540
|
+
for (var i = 0; i < localStorage.length; i++) {
|
|
541
|
+
var k = localStorage.key(i);
|
|
542
|
+
if (k && k.indexOf(CACHE_NS) === 0) keys.push(k);
|
|
543
|
+
}
|
|
544
|
+
} catch (e) {}
|
|
545
|
+
return keys;
|
|
546
|
+
}
|
|
547
|
+
function cacheSet(name, version, html) {
|
|
548
|
+
var now = Date.now();
|
|
549
|
+
var priority = cachePriority(name);
|
|
550
|
+
var rec = JSON.stringify({ fmt: CACHE_FMT, version: version, savedAt: now, usedAt: now, priority: priority, html: html });
|
|
551
|
+
try { localStorage.setItem(cacheKey(name), rec); return true; }
|
|
552
|
+
catch (e) {
|
|
553
|
+
// Make room by evicting the LEAST VALUABLE resident first — lowest priority,
|
|
554
|
+
// and within a priority the least recently USED. Crucially, never evict a
|
|
555
|
+
// page MORE important than the one being stored: a cheap page runs uncached
|
|
556
|
+
// rather than displacing an editor. `self` is excluded — a stale copy of
|
|
557
|
+
// THIS page is replaced by the setItem above (a same-key overwrite), not
|
|
558
|
+
// evicted, so removing it here would just churn.
|
|
559
|
+
var self = cacheKey(name);
|
|
560
|
+
var victims = ownKeys()
|
|
561
|
+
.filter(function (k) { return k.indexOf(CACHE_NS + 'page.') === 0 && k !== self; })
|
|
562
|
+
.map(function (k) {
|
|
563
|
+
var r = {}; try { r = JSON.parse(localStorage.getItem(k)) || {}; } catch (e2) {}
|
|
564
|
+
var pri = typeof r.priority === 'number' ? r.priority : CACHE_PRI.page;
|
|
565
|
+
return { k: k, pri: pri, at: r.usedAt || r.savedAt || 0 };
|
|
566
|
+
})
|
|
567
|
+
.filter(function (v) { return v.pri <= priority; })
|
|
568
|
+
.sort(function (a, b) { return a.pri - b.pri || a.at - b.at; });
|
|
569
|
+
for (var i = 0; i < victims.length; i++) {
|
|
570
|
+
try { localStorage.removeItem(victims[i].k); } catch (e2) {}
|
|
571
|
+
try { localStorage.setItem(cacheKey(name), rec); log('cache: evicted ' + victims[i].k, 'cache'); return true; } catch (e3) {}
|
|
572
|
+
}
|
|
573
|
+
log('cache: quota — running uncached', 'err');
|
|
574
|
+
return false;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
function haveVersions() {
|
|
578
|
+
var v = {};
|
|
579
|
+
ownKeys().forEach(function (k) {
|
|
580
|
+
if (k.indexOf(CACHE_NS + 'page.') !== 0) return;
|
|
581
|
+
try {
|
|
582
|
+
var rec = JSON.parse(localStorage.getItem(k));
|
|
583
|
+
if (rec && rec.fmt === CACHE_FMT) v[k.slice((CACHE_NS + 'page.').length)] = rec.version;
|
|
584
|
+
} catch (e) {}
|
|
585
|
+
});
|
|
586
|
+
return v;
|
|
587
|
+
}
|
|
588
|
+
function cacheSummary() {
|
|
589
|
+
var n = 0, chars = 0;
|
|
590
|
+
ownKeys().forEach(function (k) { n++; try { chars += (localStorage.getItem(k) || '').length; } catch (e) {} });
|
|
591
|
+
$('stCacheT').textContent = n ? (n + '·' + Math.round(chars / 1024) + 'KB') : '';
|
|
592
|
+
}
|
|
593
|
+
$('btnClear').addEventListener('click', function () {
|
|
594
|
+
ownKeys().forEach(function (k) { try { localStorage.removeItem(k); } catch (e) {} });
|
|
595
|
+
log('cache: cleared own keys', 'cache');
|
|
596
|
+
cacheSummary();
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
// ---- context cache ------------------------------------------------------
|
|
600
|
+
var ctxCache = { payload: null, at: 0 };
|
|
601
|
+
var ctxStats = { hits: 0, misses: 0, invalidations: 0 };
|
|
602
|
+
function ctxFresh() { return !!(ctxCache.payload && (Date.now() - ctxCache.at) < CTX_TTL_MS); }
|
|
603
|
+
function ctxInvalidate(why) {
|
|
604
|
+
if (ctxCache.payload) {
|
|
605
|
+
ctxCache.payload = null; ctxCache.at = 0; ctxStats.invalidations++;
|
|
606
|
+
log('ctx: invalidated (' + why + ')', 'cache');
|
|
607
|
+
setTimeout(updateCtxSeg, 600);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// ---- tool calls (the one choke point) -----------------------------------
|
|
612
|
+
function isErr(text) { return { isError: true, content: [{ type: 'text', text: text }] }; }
|
|
613
|
+
function firstText(r) {
|
|
614
|
+
if (!r) return '';
|
|
615
|
+
if (Array.isArray(r.content))
|
|
616
|
+
return r.content.map(function (c) { return (c && c.text) || ''; }).join('');
|
|
617
|
+
return typeof r === 'string' ? r : JSON.stringify(r);
|
|
618
|
+
}
|
|
619
|
+
function shellCall(name, args) {
|
|
620
|
+
var b = bare(name);
|
|
621
|
+
var t0 = Date.now();
|
|
622
|
+
if (!LIVE) return Promise.resolve(isErr('FX: no bridge on this surface.'));
|
|
623
|
+
if (!grantedBare[b]) {
|
|
624
|
+
log('✗ ' + b + ' — not in grant', 'err');
|
|
625
|
+
details.rpc.push({ tool: b, ok: false, ms: 0, error: 'not-in-grant' });
|
|
626
|
+
return Promise.resolve(isErr('FX: tool "' + b + '" is not in this artifact’s grant (' +
|
|
627
|
+
Object.keys(grantedBare).join(', ') + '). Ask a desktop conversation to reinstall it with its Fenix permissions.'));
|
|
628
|
+
}
|
|
629
|
+
var isCtxRead = (b === 'read' && args && args.resource === 'context');
|
|
630
|
+
if (isCtxRead && ctxFresh()) {
|
|
631
|
+
ctxStats.hits++;
|
|
632
|
+
details.rpc.push({ tool: 'read:context', ok: true, ms: 0, cached: true });
|
|
633
|
+
return Promise.resolve(ctxCache.payload);
|
|
634
|
+
}
|
|
635
|
+
var label = b + (args && args.resource ? ':' + args.resource : '');
|
|
636
|
+
return window.cowork.callMcpTool(PREFIX + b, args || {}).then(function (r) {
|
|
637
|
+
var ms = Date.now() - t0;
|
|
638
|
+
var bad = r && r.isError;
|
|
639
|
+
log((bad ? '✗ ' : '✓ ') + label + ' — ' + ms + 'ms', bad ? 'err' : 'ok');
|
|
640
|
+
details.rpc.push({ tool: label, ok: !bad, ms: ms, error: bad ? firstText(r).slice(0, 200) : undefined });
|
|
641
|
+
if (!bad) {
|
|
642
|
+
try { localStorage.setItem('fx.installed.' + ART_ID, '1'); } catch (e) {}
|
|
643
|
+
if (isCtxRead) { ctxCache.payload = r; ctxCache.at = Date.now(); ctxStats.misses++; }
|
|
644
|
+
else if (b === 'write') ctxInvalidate('write:' + ((args && args.resource) || '?'));
|
|
645
|
+
// any write may move context (writes update context by default);
|
|
646
|
+
// the wrapper needs no knowledge of which resources are matter-scoped.
|
|
647
|
+
}
|
|
648
|
+
return r;
|
|
649
|
+
}, function (e) {
|
|
650
|
+
var ms = Date.now() - t0;
|
|
651
|
+
log('✗ ' + label + ' — threw (' + ms + 'ms)', 'err');
|
|
652
|
+
details.rpc.push({ tool: label, ok: false, ms: ms, error: ('threw: ' + ((e && e.message) || e)).slice(0, 200) });
|
|
653
|
+
return isErr('FX: call threw: ' + ((e && e.message) || String(e)));
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// ---- RPC bridge + shim --------------------------------------------------
|
|
658
|
+
var frame = null;
|
|
659
|
+
window.addEventListener('message', function (e) {
|
|
660
|
+
var d = e.data;
|
|
661
|
+
if (!d) return;
|
|
662
|
+
if (d.__fx === 'call') {
|
|
663
|
+
shellCall(d.tool, d.args).then(function (r) {
|
|
664
|
+
if (frame && frame.contentWindow)
|
|
665
|
+
frame.contentWindow.postMessage({ __fx: 'result', seq: d.seq, payload: r }, '*');
|
|
666
|
+
});
|
|
667
|
+
} else if (d.__fx === 'open' && d.page) {
|
|
668
|
+
openPage(String(d.page)); // navigation is content-driven
|
|
669
|
+
} else if (d.__fx === 'showing' && d.address) {
|
|
670
|
+
// The mounted page navigated INSIDE itself. Nothing to fetch; just record
|
|
671
|
+
// where it is, so the context poll recognises the address as current.
|
|
672
|
+
if (currentPage) currentPage.address = String(d.address);
|
|
673
|
+
updateDraftBtn();
|
|
674
|
+
} else if (d.__fx === 'pageError') {
|
|
675
|
+
// A page that died while its modules were still evaluating. It reports
|
|
676
|
+
// itself through the prelude that build-nodes.mjs puts at the head of every
|
|
677
|
+
// node bundle — the shell cannot see inside the frame, and until this
|
|
678
|
+
// existed such a failure was invisible from out here: the page fetch had
|
|
679
|
+
// SUCCEEDED, so the status line stayed green and `errors: []` was the
|
|
680
|
+
// honest-looking lie in every copied diagnostic.
|
|
681
|
+
var pe = String(d.text || 'The page failed to start.');
|
|
682
|
+
details.errors.push('page runtime: ' + pe.slice(0, 300));
|
|
683
|
+
setConn('bad', 'page failed', 'The page failed to start.');
|
|
684
|
+
showErr('The page failed to start', pe);
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
/**
|
|
688
|
+
* The shim, built per mount so it can carry the ADDRESS this page was opened at.
|
|
689
|
+
*
|
|
690
|
+
* A page is cached and served by TEMPLATE — every matter shares one copy of
|
|
691
|
+
* `tools/patent/claims` — so the html itself cannot say which matter or which
|
|
692
|
+
* subview was asked for. The editor was left to guess, and it guessed "claims"
|
|
693
|
+
* every time: opening `tools/patent/figures/<id>` landed on Claims. Harmless
|
|
694
|
+
* while nothing produced subview addresses; not harmless now that the Draft
|
|
695
|
+
* button and auto-switch both do.
|
|
696
|
+
*
|
|
697
|
+
* The wrapper is the only thing that knows, because it is what asked. So it
|
|
698
|
+
* tells the page, here, rather than the page inferring it from a context whose
|
|
699
|
+
* navPath is set by the WEBSITE and says nothing about what this frame opened.
|
|
700
|
+
*/
|
|
701
|
+
function shimFor(address, name) {
|
|
702
|
+
// JSON.stringify, then neutralise every "<" as \u003c. The result is
|
|
703
|
+
// embedded in an inline script, and a closing script tag appearing inside a
|
|
704
|
+
// value would end the block early. (Note this comment carefully does not
|
|
705
|
+
// write that tag out either — the same rule applies to comments, and one
|
|
706
|
+
// spelled out here as an example is what a parse check caught.)
|
|
707
|
+
var page = JSON.stringify({ address: address || null, name: name || null }).replace(/</g, '\\u003c');
|
|
708
|
+
return '<script>(function(){' +
|
|
709
|
+
'var seq=0,pending={};' +
|
|
710
|
+
'window.addEventListener("message",function(e){var d=e.data;if(!d||d.__fx!=="result")return;' +
|
|
711
|
+
'var p=pending[d.seq];if(!p)return;delete pending[d.seq];p(d.payload);});' +
|
|
712
|
+
'function call(name,args){return new Promise(function(res){' +
|
|
713
|
+
'var s=++seq;pending[s]=res;' +
|
|
714
|
+
'setTimeout(function(){if(pending[s]){delete pending[s];' +
|
|
715
|
+
'res({isError:true,content:[{type:"text",text:"FX: call timed out after 30s"}]});}},30000);' +
|
|
716
|
+
'parent.postMessage({__fx:"call",seq:s,tool:String(name),args:args||{}},"*");});}' +
|
|
717
|
+
'function open(page){parent.postMessage({__fx:"open",page:String(page)},"*");}' +
|
|
718
|
+
// A page that serves several addresses (collections is one bundle for all
|
|
719
|
+
// its lists) can move between them WITHOUT a fetch. It tells the wrapper
|
|
720
|
+
// where it went, so the wrapper does not later "correct" it by re-opening
|
|
721
|
+
// an address it is already showing.
|
|
722
|
+
'function showing(addr){parent.postMessage({__fx:"showing",address:String(addr)},"*");}' +
|
|
723
|
+
'window.cowork={callMcpTool:call,openPage:open,setPage:showing,__fxShim:true,page:' + page + '};' +
|
|
724
|
+
'})();<\/script>';
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* The runtime as a <script> for splicing into a frame.
|
|
728
|
+
*
|
|
729
|
+
* A closing script tag anywhere in the bundle would end the block early and
|
|
730
|
+
* leave the rest as page text, so neutralise it — the same rule shimFor follows
|
|
731
|
+
* for its JSON. Written `<\/script` here so this file's OWN script block is not
|
|
732
|
+
* terminated by the pattern that describes it.
|
|
733
|
+
*/
|
|
734
|
+
function vendorScript() {
|
|
735
|
+
if (!vendorJs) return '';
|
|
736
|
+
return '<script>' + vendorJs.replace(/<\/script/gi, '<\\/script') + '<\/script>';
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* Make sure the shared runtime is in hand, then continue.
|
|
741
|
+
*
|
|
742
|
+
* One version check per session: pass the cached version and an unchanged
|
|
743
|
+
* runtime answers notModified with no payload. Every failure path still calls
|
|
744
|
+
* back — a page that cannot get the runtime should mount and let the editor
|
|
745
|
+
* report a missing runtime itself, which it does clearly, rather than hang on a
|
|
746
|
+
* blank frame.
|
|
747
|
+
*/
|
|
748
|
+
function ensureVendor(done) {
|
|
749
|
+
if (vendorChecked) return done();
|
|
750
|
+
var cached = cacheGet(VENDOR_NAME);
|
|
751
|
+
var args = { resource: 'page', name: VENDOR_NAME };
|
|
752
|
+
if (cached) { args.haveVersions = {}; args.haveVersions[VENDOR_NAME] = cached.version; }
|
|
753
|
+
shellCall('read', args).then(function (r) {
|
|
754
|
+
vendorChecked = true;
|
|
755
|
+
try {
|
|
756
|
+
if (r && !r.isError) {
|
|
757
|
+
var p = JSON.parse(firstText(r));
|
|
758
|
+
if (p.notModified) {
|
|
759
|
+
if (cached) vendorJs = cached.html;
|
|
760
|
+
} else if (typeof p.html === 'string' && p.html) {
|
|
761
|
+
vendorJs = p.html;
|
|
762
|
+
cacheSet(VENDOR_NAME, p.version, p.html);
|
|
763
|
+
log('runtime: ' + VENDOR_NAME + ' v' + p.version + ' (' + Math.round(p.html.length / 1024) + 'KB)', 'cache');
|
|
764
|
+
}
|
|
765
|
+
} else if (r && r.isError) {
|
|
766
|
+
details.errors.push('runtime: ' + firstText(r).slice(0, 200));
|
|
767
|
+
}
|
|
768
|
+
} catch (e) {}
|
|
769
|
+
if (!vendorJs && cached) vendorJs = cached.html; // last resort: whatever we held
|
|
770
|
+
if (!vendorJs) log('runtime unavailable — the editor will ask for a workspace update', 'err');
|
|
771
|
+
done();
|
|
772
|
+
}, function () {
|
|
773
|
+
vendorChecked = true;
|
|
774
|
+
if (cached) vendorJs = cached.html;
|
|
775
|
+
done();
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function injectShim(html, address, name) {
|
|
780
|
+
var head = shimFor(address, name);
|
|
781
|
+
// The runtime goes in FIRST: it must define __FXV before the page's own
|
|
782
|
+
// bundle runs, and that bundle is a later <script> in the body.
|
|
783
|
+
if (needsVendor(name) && vendorJs) head = vendorScript() + head;
|
|
784
|
+
var m = html.match(/<head[^>]*>/i);
|
|
785
|
+
if (m) return html.slice(0, m.index + m[0].length) + head + html.slice(m.index + m[0].length);
|
|
786
|
+
m = html.match(/<html[^>]*>/i);
|
|
787
|
+
if (m) return html.slice(0, m.index + m[0].length) + head + html.slice(m.index + m[0].length);
|
|
788
|
+
return head + html;
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* `address` is the FULL path asked for (id and all); `name` is its template,
|
|
792
|
+
* which is the cache key. The page needs the address — it is the only thing
|
|
793
|
+
* carrying which subview and which matter — while the wrapper tracks by
|
|
794
|
+
* template so two matters share one cached copy.
|
|
795
|
+
*/
|
|
796
|
+
function mountPage(html, name, version, how, address) {
|
|
797
|
+
// An editor cannot start without the shared runtime, so fetch it first and
|
|
798
|
+
// mount in the callback. ensureVendor short-circuits once it has resolved, so
|
|
799
|
+
// this costs one call per session, not one per open.
|
|
800
|
+
if (needsVendor(name) && !vendorJs) {
|
|
801
|
+
return ensureVendor(function () { mountPageNow(html, name, version, how, address); });
|
|
802
|
+
}
|
|
803
|
+
mountPageNow(html, name, version, how, address);
|
|
804
|
+
}
|
|
805
|
+
function mountPageNow(html, name, version, how, address) {
|
|
806
|
+
var main = $('main');
|
|
807
|
+
if (frame) { main.removeChild(frame); frame = null; }
|
|
808
|
+
frame = document.createElement('iframe');
|
|
809
|
+
main.appendChild(frame);
|
|
810
|
+
frame.srcdoc = injectShim(html, address || name, name);
|
|
811
|
+
currentPage = { name: name, version: version, address: address || name };
|
|
812
|
+
// Visibility depends on where we now ARE, not only on what the context says.
|
|
813
|
+
updateDraftBtn();
|
|
814
|
+
log('page: ' + name + ' v' + version + ' (' + how + ', ' + Math.round(html.length / 1024) + 'KB)');
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// ---- page delivery via the read tool ------------------------------------
|
|
818
|
+
// read {resource:'page'} → server-chosen entry page
|
|
819
|
+
// read {resource:'page', name:<n>} → that page
|
|
820
|
+
// both accept haveVersions:{name:ver} → server may answer notModified
|
|
821
|
+
// response: {name, version, html} | {name, version, notModified:true}
|
|
822
|
+
/**
|
|
823
|
+
* NAVIGATION BEATS THE POLL.
|
|
824
|
+
*
|
|
825
|
+
* A page read carrying an id also SETS the working context — that is the
|
|
826
|
+
* server's rule, arriving at an address is navigating. So between asking for a
|
|
827
|
+
* page and that call returning, the context on the server is still the OLD
|
|
828
|
+
* one. A poll landing in that window reads the old context, decides the
|
|
829
|
+
* workspace is in the wrong place, and (with auto-switch on) navigates back —
|
|
830
|
+
* undoing the click that was still in flight.
|
|
831
|
+
*
|
|
832
|
+
* So polling is suspended for the whole of a navigation and resumes only once
|
|
833
|
+
* the call that moves the context has ANSWERED. The counter rather than a
|
|
834
|
+
* boolean because a navigation can nest: notModified refetches the page a
|
|
835
|
+
* second time before it mounts.
|
|
836
|
+
*/
|
|
837
|
+
var navPending = 0;
|
|
838
|
+
// Bumped every time a navigation BEGINS. The poll snapshots it before issuing
|
|
839
|
+
// its context read and re-checks it when the read returns: if a navigation
|
|
840
|
+
// started in that window, the reply describes where we WERE, and acting on it
|
|
841
|
+
// (openPage of the old target, under auto-switch) would undo the move just
|
|
842
|
+
// made. navPending alone cannot catch this — it is 0 when the poll starts and
|
|
843
|
+
// only goes up mid-flight, so the guard at the top of updateCtxSeg passes and
|
|
844
|
+
// the stale reply is processed anyway. The generation counter closes that gap.
|
|
845
|
+
var navGen = 0;
|
|
846
|
+
function beginNav() { navPending++; navGen++; }
|
|
847
|
+
function endNav() {
|
|
848
|
+
navPending = Math.max(0, navPending - 1);
|
|
849
|
+
if (navPending === 0) {
|
|
850
|
+
// The context has moved and the server has confirmed it. Drop the cached
|
|
851
|
+
// rev so the next poll takes the full reply rather than a notModified
|
|
852
|
+
// computed against what we knew before we moved.
|
|
853
|
+
ctxRev = null;
|
|
854
|
+
setTimeout(updateCtxSeg, 0);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Addresses the server has told us no artifact draws, as {address: message}.
|
|
860
|
+
*
|
|
861
|
+
* THIS IS A LOOP BREAKER, and the loop was real. A failed openPage still calls
|
|
862
|
+
* endNav(), which drops ctxRev so the next poll takes a FULL context reply
|
|
863
|
+
* rather than a notModified. That reply names the same unreachable target, the
|
|
864
|
+
* follow logic opens it, it fails, and round again — one fetch every three
|
|
865
|
+
* seconds, forever, each one re-raising the same error. (Observed against
|
|
866
|
+
* `dashboards/project/<id>`, which the context URL points at and which nothing
|
|
867
|
+
* draws.) Latched only for the server's deterministic "real page, nothing
|
|
868
|
+
* draws it" answer: a transient failure must stay retryable, and this one will
|
|
869
|
+
* never come out differently until the artifact ships.
|
|
870
|
+
*/
|
|
871
|
+
var undrawn = {};
|
|
872
|
+
var UNDRAWN_RE = /no artifact draws it yet/i;
|
|
873
|
+
|
|
874
|
+
function openPage(name) {
|
|
875
|
+
// The address requested, kept for the mount below: the server answers with a
|
|
876
|
+
// TEMPLATE name (the id stripped), so by the time the html arrives the
|
|
877
|
+
// subview and matter are no longer in the reply.
|
|
878
|
+
var requested = name || null;
|
|
879
|
+
if (name && undrawn[name]) return; // already answered, definitively
|
|
880
|
+
var args = { resource: 'page', haveVersions: haveVersions() };
|
|
881
|
+
if (name) args.name = name;
|
|
882
|
+
beginNav();
|
|
883
|
+
shellCall('read', args).then(function (r) {
|
|
884
|
+
if (r && r.isError) {
|
|
885
|
+
var msg = firstText(r);
|
|
886
|
+
details.errors.push('page ' + (name || '(entry)') + ': ' + msg.slice(0, 300));
|
|
887
|
+
setConn(details.kind === 'ready' ? 'warn' : 'bad', 'page error', 'Fenix answered with an error.');
|
|
888
|
+
if (name && UNDRAWN_RE.test(msg)) {
|
|
889
|
+
undrawn[name] = msg;
|
|
890
|
+
// Not a malfunction — a place the workspace does not go yet, and the
|
|
891
|
+
// page you were on is still there and still usable. Say it on the
|
|
892
|
+
// line and leave the page alone; the badge carries the detail.
|
|
893
|
+
pendingTarget = null;
|
|
894
|
+
$('ctxGo').style.display = 'none';
|
|
895
|
+
setConn(details.kind === 'ready' ? 'warn' : 'bad', 'not here',
|
|
896
|
+
name + ' opens on the website — nothing draws it here yet.');
|
|
897
|
+
}
|
|
898
|
+
showErr('Fenix could not open ' + (name || 'the entry page'), msg);
|
|
899
|
+
endNav();
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
var p;
|
|
903
|
+
try { p = JSON.parse(firstText(r)); } catch (e) {
|
|
904
|
+
log('✗ page parse: ' + e.message, 'err');
|
|
905
|
+
showErr('Fenix sent a page this shell could not read',
|
|
906
|
+
e.message + '\n\n--- response ---\n' + firstText(r).slice(0, 4000));
|
|
907
|
+
endNav();
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
if (p.notModified) {
|
|
911
|
+
var c = cacheGet(p.name);
|
|
912
|
+
if (c && c.version === p.version) { mountPage(c.html, p.name, p.version, 'cache', requested); cacheSummary(); endNav(); return; }
|
|
913
|
+
// By ADDRESS, not by the template the server answered with. A page read
|
|
914
|
+
// publishes where you are (see recordPageArrival), so re-requesting
|
|
915
|
+
// `tools/patent` instead of `tools/patent/claims/<id>` would drop the
|
|
916
|
+
// matter out of the address every other surface follows.
|
|
917
|
+
shellCall('read', { resource: 'page', name: requested || p.name }).then(function (r2) {
|
|
918
|
+
if (r2 && r2.isError) { details.errors.push('page refetch: ' + firstText(r2).slice(0, 300)); endNav(); return; }
|
|
919
|
+
var p2; try { p2 = JSON.parse(firstText(r2)); } catch (e) { endNav(); return; }
|
|
920
|
+
if (typeof p2.html === 'string') {
|
|
921
|
+
if (p2.cache !== false) cacheSet(p2.name, p2.version, p2.html);
|
|
922
|
+
mountPage(p2.html, p2.name, p2.version, 'fetched', requested);
|
|
923
|
+
cacheSummary();
|
|
924
|
+
}
|
|
925
|
+
endNav();
|
|
926
|
+
});
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
if (typeof p.html === 'string' && p.html) {
|
|
930
|
+
// cache:false means the HTML has live data baked into it — a builder
|
|
931
|
+
// page rendered server-side. Storing it would pin the numbers as they
|
|
932
|
+
// were at fetch time and show them again on the next open.
|
|
933
|
+
if (p.cache === false) {
|
|
934
|
+
try { localStorage.removeItem(cacheKey(p.name)); } catch (e) {}
|
|
935
|
+
mountPage(p.html, p.name, p.version, 'fetched', requested);
|
|
936
|
+
cacheSummary();
|
|
937
|
+
endNav();
|
|
938
|
+
return;
|
|
939
|
+
}
|
|
940
|
+
var cc = cacheGet(p.name);
|
|
941
|
+
if (!cc || cc.version !== p.version) cacheSet(p.name, p.version, p.html);
|
|
942
|
+
mountPage(p.html, p.name, p.version, (cc && cc.version === p.version) ? 'refreshed' : 'fetched', requested);
|
|
943
|
+
cacheSummary();
|
|
944
|
+
} else {
|
|
945
|
+
log('✗ page response had no html', 'err');
|
|
946
|
+
}
|
|
947
|
+
endNav();
|
|
948
|
+
}, function () {
|
|
949
|
+
// The call itself never rejects (shellCall converts throws into error
|
|
950
|
+
// envelopes), but releasing here too means no future change to that can
|
|
951
|
+
// strand the guard and freeze the poll permanently.
|
|
952
|
+
endNav();
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
$('btnReload').addEventListener('click', function () {
|
|
956
|
+
if (currentPage) {
|
|
957
|
+
try { localStorage.removeItem(cacheKey(currentPage.name)); } catch (e) {}
|
|
958
|
+
// Reopen the ADDRESS, not the template — reloading must not silently drop
|
|
959
|
+
// the matter and subview you were on.
|
|
960
|
+
openPage(currentPage.address || currentPage.name);
|
|
961
|
+
} else openPage(null);
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
// ---- context segment ----------------------------------------------------
|
|
965
|
+
/**
|
|
966
|
+
* The context, as a label and — when the server can name one — a URL.
|
|
967
|
+
*
|
|
968
|
+
* `url` is derived server-side in get_context from the session's navPath (else
|
|
969
|
+
* its bound matter), because only the server knows the tenant's subdomain. The
|
|
970
|
+
* shell never builds it: a guessed address is worse than no address.
|
|
971
|
+
*/
|
|
972
|
+
var ctxUrlValue = null;
|
|
973
|
+
function parseCtx(r) {
|
|
974
|
+
var t = firstText(r);
|
|
975
|
+
try {
|
|
976
|
+
var j = JSON.parse(t);
|
|
977
|
+
var o = j.session || j.context || j;
|
|
978
|
+
var id = o.label || o.matterNumber || o.docket || o.projectNumber || o.title || o.name ||
|
|
979
|
+
(o.ids && o.ids.projectId) || o.projectId || null;
|
|
980
|
+
var src = o.source ? (' · ' + o.source) : '';
|
|
981
|
+
var url = typeof o.url === 'string' && o.url ? o.url : null;
|
|
982
|
+
// The ADDRESS the context points at — what the workspace would open if it
|
|
983
|
+
// were following. navPath first (it names a specific view), the bound
|
|
984
|
+
// matter as the fallback. Never derived from the url: that is absolute and
|
|
985
|
+
// tenant-specific, while pages are addressed by nav path.
|
|
986
|
+
var target = typeof o.navPath === 'string' && o.navPath
|
|
987
|
+
? o.navPath.replace(/^\/+/, '')
|
|
988
|
+
: (o.ids && o.ids.projectId ? 'collections/projects/' + o.ids.projectId : null);
|
|
989
|
+
if (id || url) return { label: id ? String(id) + src : '—', url: url, target: target, full: t };
|
|
990
|
+
} catch (e) {}
|
|
991
|
+
t = (t || '').replace(/\s+/g, ' ').trim();
|
|
992
|
+
return { label: t ? t.slice(0, 40) : '—', url: null, target: null, full: t };
|
|
993
|
+
}
|
|
994
|
+
// ---- following the context ----------------------------------------------
|
|
995
|
+
//
|
|
996
|
+
// WHY THIS IS A CHOICE AND NOT A BEHAVIOUR. Chasing the context automatically
|
|
997
|
+
// is right when the workspace is a window onto whatever the conversation is
|
|
998
|
+
// doing, and WRONG when someone is mid-edit: the patent editor deliberately
|
|
999
|
+
// reads context once and PINS it, precisely so a matter cannot be swapped out
|
|
1000
|
+
// from under an open editor. Both are legitimate; only the person knows which
|
|
1001
|
+
// they are doing. So the wrapper follows by default and the toggle turns it
|
|
1002
|
+
// off, and with it off a move becomes an offer ("open →") instead of a jump.
|
|
1003
|
+
var ctxRev = null; // last rev seen; sent as haveRev so the poll is cheap
|
|
1004
|
+
var pendingTarget = null; // where the context points, when we have not gone there
|
|
1005
|
+
function following() {
|
|
1006
|
+
try { return localStorage.getItem(FOLLOW_KEY) !== '0'; } catch (e) { return true; }
|
|
1007
|
+
}
|
|
1008
|
+
function setFollowing(on) {
|
|
1009
|
+
try { localStorage.setItem(FOLLOW_KEY, on ? '1' : '0'); } catch (e) {}
|
|
1010
|
+
$('follow').checked = on;
|
|
1011
|
+
// Turning it back on adopts whatever was waiting, which is what someone
|
|
1012
|
+
// ticking the box is asking for.
|
|
1013
|
+
if (on && pendingTarget) openPage(pendingTarget);
|
|
1014
|
+
}
|
|
1015
|
+
$('follow').addEventListener('change', function () { setFollowing($('follow').checked); });
|
|
1016
|
+
$('ctxGo').addEventListener('click', function () { if (pendingTarget) openPage(pendingTarget); });
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* The "Draft" control: open the current matter in its editor.
|
|
1020
|
+
*
|
|
1021
|
+
* The ADDRESS comes from the server (`draft` on the context reply), not from
|
|
1022
|
+
* here. Choosing between the application editor and the OA editor means
|
|
1023
|
+
* knowing project types, and this wrapper deliberately knows nothing about
|
|
1024
|
+
* Fenix beyond two tool names — a copy of that rule living here is a copy that
|
|
1025
|
+
* will disagree with the editor's own classifier, and the failure mode is
|
|
1026
|
+
* silent: the OA tool opened on a drafting matter, minus its figures,
|
|
1027
|
+
* specification and disclosure tabs.
|
|
1028
|
+
*
|
|
1029
|
+
* Hidden on the editors themselves. A button whose only effect is to reload
|
|
1030
|
+
* the page you are already on is noise, and worse, it reads as though it does
|
|
1031
|
+
* something.
|
|
1032
|
+
*/
|
|
1033
|
+
var draftTarget = null;
|
|
1034
|
+
var navTargets = null; // {category: address|null}, named by the server
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* The category icons, drawn from whatever the server named.
|
|
1038
|
+
*
|
|
1039
|
+
* The LIST comes from the server's `nav` object, not from a list here: adding
|
|
1040
|
+
* a category is then a server change, and an installed wrapper picks it up
|
|
1041
|
+
* without a reinstall — the one kind of change this architecture exists to
|
|
1042
|
+
* avoid. The icons and labels below are presentation, so they do live here;
|
|
1043
|
+
* an unknown category still renders, with its name as the label, rather than
|
|
1044
|
+
* silently vanishing.
|
|
1045
|
+
*/
|
|
1046
|
+
// No `admin` — not a destination the workspace offers. It is still a real
|
|
1047
|
+
// CATEGORY (admin/logs is a page, and lands here as "no icon active"), just
|
|
1048
|
+
// not somewhere this bar navigates to. Adding it later is a server change:
|
|
1049
|
+
// the server names the destinations and anything it names renders.
|
|
1050
|
+
var CAT_ICON = { tools: '🔧', collections: '📚', dashboards: '▦' };
|
|
1051
|
+
var CAT_LABEL = { tools: 'Editor', collections: 'Collections', dashboards: 'Dashboards' };
|
|
1052
|
+
var CAT_ORDER = ['tools', 'collections', 'dashboards'];
|
|
1053
|
+
|
|
1054
|
+
/** The category of the page currently mounted — the first path segment. */
|
|
1055
|
+
function currentCategory() {
|
|
1056
|
+
if (!currentPage || !currentPage.name) return null;
|
|
1057
|
+
return String(currentPage.name).split('/')[0] || null;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function renderNavCats() {
|
|
1061
|
+
var host = $('navCats');
|
|
1062
|
+
if (!navTargets) { host.innerHTML = ''; return; }
|
|
1063
|
+
var here = currentCategory();
|
|
1064
|
+
var keys = CAT_ORDER.filter(function (k) { return k in navTargets; })
|
|
1065
|
+
.concat(Object.keys(navTargets).filter(function (k) { return CAT_ORDER.indexOf(k) < 0; }));
|
|
1066
|
+
|
|
1067
|
+
host.innerHTML = '';
|
|
1068
|
+
keys.forEach(function (cat) {
|
|
1069
|
+
var target = navTargets[cat];
|
|
1070
|
+
var active = cat === here;
|
|
1071
|
+
var b = document.createElement('button');
|
|
1072
|
+
b.type = 'button';
|
|
1073
|
+
b.textContent = CAT_ICON[cat] || '•';
|
|
1074
|
+
var label = CAT_LABEL[cat] || cat;
|
|
1075
|
+
if (active) {
|
|
1076
|
+
b.className = 'active';
|
|
1077
|
+
b.disabled = true;
|
|
1078
|
+
b.title = label + ' — you are here';
|
|
1079
|
+
b.setAttribute('aria-current', 'page');
|
|
1080
|
+
} else if (!target) {
|
|
1081
|
+
// Only reachable for `tools`, which needs a bound matter.
|
|
1082
|
+
b.className = 'empty';
|
|
1083
|
+
b.disabled = true;
|
|
1084
|
+
b.title = label + ' — no matter is open, so there is nothing to draft';
|
|
1085
|
+
} else {
|
|
1086
|
+
b.title = 'Go to ' + label +
|
|
1087
|
+
(cat === 'tools' && draftTarget
|
|
1088
|
+
? (draftTarget.kind === 'oa' ? ' (OA response)' : ' (application)')
|
|
1089
|
+
: '') + ' — ' + target;
|
|
1090
|
+
b.onclick = function () { openPage(target); };
|
|
1091
|
+
}
|
|
1092
|
+
b.setAttribute('aria-label', label);
|
|
1093
|
+
host.appendChild(b);
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
/**
|
|
1097
|
+
* THE MATTER SELECTOR LIVES IN THE PAGE, not here.
|
|
1098
|
+
*
|
|
1099
|
+
* It was briefly in this bar, and that was wrong for a reason worth writing
|
|
1100
|
+
* down: a matter is not relevant to every page. A collections list, a
|
|
1101
|
+
* dashboard — most of what the workspace shows is not ABOUT one matter, and a
|
|
1102
|
+
* control that is meaningless on most pages still has to be explained on all
|
|
1103
|
+
* of them. It also cost the wrapper knowledge it otherwise does not need
|
|
1104
|
+
* (what a matter is, how they are listed and labelled) inside an INSTALLED
|
|
1105
|
+
* artifact, where changes cost a reinstall.
|
|
1106
|
+
*
|
|
1107
|
+
* The editor owns it again, because that is the page where the matter IS the
|
|
1108
|
+
* subject. The wrapper still names the current one in its status line, which
|
|
1109
|
+
* is the part that genuinely belongs to every page.
|
|
1110
|
+
*/
|
|
1111
|
+
|
|
1112
|
+
/** Kept as the old name so the mount/context call sites read the same. */
|
|
1113
|
+
function updateDraftBtn() { renderNavCats(); }
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* The context moved. Decide what the workspace does about it.
|
|
1117
|
+
*
|
|
1118
|
+
* `target` is the address the context points at — navPath, else the bound
|
|
1119
|
+
* matter. Compared against the TEMPLATE of what is mounted, because
|
|
1120
|
+
* currentPage.name is the address with its id stripped: two matters share one
|
|
1121
|
+
* page, so comparing raw addresses would re-navigate on every matter change
|
|
1122
|
+
* even when the page is identical.
|
|
1123
|
+
*/
|
|
1124
|
+
function onContextTarget(target) {
|
|
1125
|
+
if (!target) { pendingTarget = null; $('ctxGo').style.display = 'none'; return; }
|
|
1126
|
+
/*
|
|
1127
|
+
* Already showing it?
|
|
1128
|
+
*
|
|
1129
|
+
* Three ways that can be true, and the third is the one that matters now
|
|
1130
|
+
* that pages write context when they navigate internally:
|
|
1131
|
+
*
|
|
1132
|
+
* - the target IS the mounted template;
|
|
1133
|
+
* - it is an address UNDER that template (same page, another matter);
|
|
1134
|
+
* - it is the exact address the page last reported showing. A bundle that
|
|
1135
|
+
* serves several addresses moves between them without a fetch, so the
|
|
1136
|
+
* mounted TEMPLATE stops being the whole truth about where we are.
|
|
1137
|
+
*
|
|
1138
|
+
* Without the third, clicking a tab inside collections wrote the context,
|
|
1139
|
+
* the next poll saw an address that did not match the template, and the
|
|
1140
|
+
* wrapper re-fetched and remounted the bundle already on screen — throwing
|
|
1141
|
+
* away the tab that had just been chosen.
|
|
1142
|
+
*/
|
|
1143
|
+
var showing = currentPage && (target === currentPage.name ||
|
|
1144
|
+
target.indexOf(currentPage.name + '/') === 0 ||
|
|
1145
|
+
target === currentPage.address);
|
|
1146
|
+
if (showing) { pendingTarget = null; $('ctxGo').style.display = 'none'; return; }
|
|
1147
|
+
// The server has already said nothing draws this. Offering it, or chasing it
|
|
1148
|
+
// because auto-switch is on, only reproduces the failure — and the context
|
|
1149
|
+
// sits on such an address for as long as the conversation is working there,
|
|
1150
|
+
// so it would reproduce it every poll. The URL on the line is the way in.
|
|
1151
|
+
if (undrawn[target]) { pendingTarget = null; $('ctxGo').style.display = 'none'; return; }
|
|
1152
|
+
pendingTarget = target;
|
|
1153
|
+
if (following()) { $('ctxGo').style.display = 'none'; openPage(target); return; }
|
|
1154
|
+
// Not following: say so, and let them choose.
|
|
1155
|
+
$('ctxGo').style.display = '';
|
|
1156
|
+
$('ctxGo').textContent = 'open ' + target.split('/').slice(0, 2).join('/') + ' →';
|
|
1157
|
+
$('ctxGo').title = 'The working context moved to ' + target + '. Click to open it here.';
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function updateCtxSeg() {
|
|
1161
|
+
if (!LIVE || !grantedBare['read']) return;
|
|
1162
|
+
// A navigation is in flight and the server has not yet been told where we
|
|
1163
|
+
// went. Reading context now would return the PREVIOUS page and, with
|
|
1164
|
+
// auto-switch on, navigate back to it — the poll undoing the click.
|
|
1165
|
+
if (navPending) return;
|
|
1166
|
+
// haveRev is the whole reason a 3s poll is reasonable: unchanged means an
|
|
1167
|
+
// empty reply and no server-side query. See get_context.
|
|
1168
|
+
var gen = navGen;
|
|
1169
|
+
var args = { resource: 'context' };
|
|
1170
|
+
if (ctxRev !== null) args.haveRev = ctxRev;
|
|
1171
|
+
shellCall('read', args).then(function (r) {
|
|
1172
|
+
// A navigation began (and maybe already finished) while this read was in
|
|
1173
|
+
// flight. Its result names the PREVIOUS location; with auto-switch on,
|
|
1174
|
+
// onContextTarget would openPage that old target and revert the move. Drop
|
|
1175
|
+
// it — endNav has nulled ctxRev, so the next poll re-reads from clean.
|
|
1176
|
+
if (navPending || navGen !== gen) return;
|
|
1177
|
+
if (r && r.isError) { $('ctxSeg').textContent = '—'; return; }
|
|
1178
|
+
var raw = firstText(r), parsedRev = null, notMod = false;
|
|
1179
|
+
try { var j = JSON.parse(raw); notMod = !!j.notModified; parsedRev = j.rev; } catch (e) {}
|
|
1180
|
+
if (typeof parsedRev === 'number') ctxRev = parsedRev;
|
|
1181
|
+
if (notMod) return; // nothing moved — and nothing to redraw
|
|
1182
|
+
|
|
1183
|
+
var p = parseCtx(r);
|
|
1184
|
+
$('ctxSeg').textContent = p.label;
|
|
1185
|
+
$('ctxSeg').title = 'Current context (click to refresh)\n' + p.full.slice(0, 600);
|
|
1186
|
+
// An artifact has no address bar. This link is the only thing on screen
|
|
1187
|
+
// that says where the page IS, and the only way back to the same thing in
|
|
1188
|
+
// Fenix. Cleared when the server names none, never guessed.
|
|
1189
|
+
ctxUrlValue = p.url;
|
|
1190
|
+
var a = $('ctxUrl');
|
|
1191
|
+
if (p.url) { a.textContent = p.url; a.href = p.url; a.title = 'Open in Fenix — ' + p.url; }
|
|
1192
|
+
else { a.textContent = ''; a.removeAttribute('href'); }
|
|
1193
|
+
// Where the Draft control goes for this matter, named by the server. Only
|
|
1194
|
+
// present on a full reply; a notModified poll returns above, so this is not
|
|
1195
|
+
// cleared by the 3s heartbeat.
|
|
1196
|
+
try {
|
|
1197
|
+
var j2 = JSON.parse(raw);
|
|
1198
|
+
draftTarget = j2 && j2.draft && j2.draft.path ? j2.draft : null;
|
|
1199
|
+
navTargets = j2 && j2.nav && typeof j2.nav === 'object' ? j2.nav : null;
|
|
1200
|
+
} catch (e) { draftTarget = null; navTargets = null; }
|
|
1201
|
+
updateDraftBtn();
|
|
1202
|
+
onContextTarget(p.target);
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
$('ctxSeg').addEventListener('click', function () {
|
|
1206
|
+
ctxRev = null; // force a full reply, not a notModified
|
|
1207
|
+
ctxInvalidate('manual refresh');
|
|
1208
|
+
updateCtxSeg();
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
// Poll fast while visible, slowly while not: a hidden workspace has nobody to
|
|
1212
|
+
// be immediate for, and the interval is the one thing multiplied by every
|
|
1213
|
+
// open copy.
|
|
1214
|
+
var pollTimer = null;
|
|
1215
|
+
function schedulePoll() {
|
|
1216
|
+
if (pollTimer) clearInterval(pollTimer);
|
|
1217
|
+
var hidden = typeof document.hidden === 'boolean' && document.hidden;
|
|
1218
|
+
pollTimer = setInterval(updateCtxSeg, hidden ? CTX_POLL_IDLE_MS : CTX_POLL_MS);
|
|
1219
|
+
}
|
|
1220
|
+
document.addEventListener('visibilitychange', function () {
|
|
1221
|
+
schedulePoll();
|
|
1222
|
+
if (!document.hidden) updateCtxSeg(); // catch up immediately on return
|
|
1223
|
+
});
|
|
1224
|
+
schedulePoll();
|
|
1225
|
+
|
|
1226
|
+
// ---- diagnostics --------------------------------------------------------
|
|
1227
|
+
setInterval(function () {
|
|
1228
|
+
$('diagStats').textContent = ART_ID + ' ' + VERSION + ' · ctx ' + ctxStats.hits + 'h/' +
|
|
1229
|
+
ctxStats.misses + 'm/' + ctxStats.invalidations + 'i · rpc ' + details.rpc.length;
|
|
1230
|
+
}, 2000);
|
|
1231
|
+
function buildReport() {
|
|
1232
|
+
var installedMarker = null;
|
|
1233
|
+
try { installedMarker = localStorage.getItem('fx.installed.' + ART_ID); } catch (e) {}
|
|
1234
|
+
var cacheState = ownKeys().map(function (k) {
|
|
1235
|
+
var chars = 0, ver = null;
|
|
1236
|
+
try { var raw = localStorage.getItem(k); chars = raw.length; ver = (JSON.parse(raw) || {}).version; } catch (e) {}
|
|
1237
|
+
return { key: k, chars: chars, version: ver };
|
|
1238
|
+
});
|
|
1239
|
+
return {
|
|
1240
|
+
kind: details.kind, artifact: ART_ID, version: VERSION,
|
|
1241
|
+
surface: LIVE ? 'installed' : 'no-bridge',
|
|
1242
|
+
deliveredMetaToolCount: grantedFq.length, deliveredMetaTools: grantedFq,
|
|
1243
|
+
manifestTools: manifest.tools, missingTools: missingTools(),
|
|
1244
|
+
prefix: PREFIX, installedMarker: installedMarker,
|
|
1245
|
+
currentPage: currentPage, contextUrl: ctxUrlValue,
|
|
1246
|
+
contextCache: { ttlMs: CTX_TTL_MS, hits: ctxStats.hits, misses: ctxStats.misses,
|
|
1247
|
+
invalidations: ctxStats.invalidations },
|
|
1248
|
+
cache: cacheState, rpc: details.rpc.slice(-60), errors: details.errors,
|
|
1249
|
+
// Addresses this session asked for and the server said nothing draws.
|
|
1250
|
+
// Names the page-coverage gaps a real session actually walked into, which
|
|
1251
|
+
// is better evidence of what to build next than the coverage list.
|
|
1252
|
+
undrawn: Object.keys(undrawn),
|
|
1253
|
+
timestamp: new Date().toISOString()
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
/** Copy `text`, reporting on `btn`. execCommand fallback: not every host exposes the async clipboard. */
|
|
1257
|
+
function copyTo(btn, text) {
|
|
1258
|
+
var label = btn.textContent;
|
|
1259
|
+
function done(suffix) { btn.textContent = 'Copied' + (suffix || ''); setTimeout(function () { btn.textContent = label; }, 2000); }
|
|
1260
|
+
function fb() {
|
|
1261
|
+
var ta = document.createElement('textarea'); ta.value = text;
|
|
1262
|
+
document.body.appendChild(ta); ta.select();
|
|
1263
|
+
try { document.execCommand('copy'); } catch (e) {}
|
|
1264
|
+
document.body.removeChild(ta);
|
|
1265
|
+
done(' (fallback)');
|
|
1266
|
+
}
|
|
1267
|
+
if (navigator.clipboard && navigator.clipboard.writeText)
|
|
1268
|
+
navigator.clipboard.writeText(text).then(function () { done(); }, fb);
|
|
1269
|
+
else fb();
|
|
1270
|
+
}
|
|
1271
|
+
$('btnCopy').addEventListener('click', function () {
|
|
1272
|
+
copyTo($('btnCopy'), JSON.stringify(buildReport(), null, 2));
|
|
1273
|
+
});
|
|
1274
|
+
|
|
1275
|
+
// ---- startup ------------------------------------------------------------
|
|
1276
|
+
cacheSummary();
|
|
1277
|
+
$('follow').checked = following();
|
|
1278
|
+
if (LIVE && details.kind !== 'no-access') {
|
|
1279
|
+
openPage(null); // server chooses the entry page (may depend on context)
|
|
1280
|
+
updateCtxSeg();
|
|
1281
|
+
}
|
|
1282
|
+
})();
|
|
1283
|
+
</script>
|
|
1284
|
+
</body>
|
|
1285
|
+
</html>
|