@erdemtuna/doc-review 0.10.0 → 0.11.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/README.md +13 -2
- package/lib/SKILL.md +5 -2
- package/lib/changes-controller.js +184 -0
- package/lib/chrome-client.js +361 -916
- package/lib/chrome-session.js +11 -14
- package/lib/chrome.html +19 -162
- package/lib/comments-controller.js +109 -0
- package/lib/contextual-controller.js +33 -0
- package/lib/controller-store.js +96 -0
- package/lib/execution-client.js +1 -51
- package/lib/feedback-controller.js +21 -14
- package/lib/feedback-panel-controller.js +161 -0
- package/lib/history-coordinator.js +7 -0
- package/lib/icons.js +76 -0
- package/lib/recovery-controller.js +123 -0
- package/lib/save-controller.js +13 -10
- package/lib/server.js +4 -4
- package/lib/toolbar-controller.js +32 -0
- package/lib/ui/THIRD_PARTY_NOTICES.md +1254 -0
- package/lib/ui/chrome.css +4881 -0
- package/lib/ui/chrome.js +52 -0
- package/package.json +31 -5
- package/lib/chrome.css +0 -652
- package/lib/comparison-view.js +0 -380
package/lib/chrome-session.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
export function commentControlId(commentId, surface, action) {
|
|
2
|
+
const id = String(commentId).replace(/[^a-zA-Z0-9_-]/g, (char) => `-${char.codePointAt(0).toString(16)}-`);
|
|
3
|
+
return `comment-${surface}-${id}-${action}`;
|
|
4
|
+
}
|
|
1
5
|
export function pageUrl(key, sessionId) {
|
|
2
6
|
return `/api/page/${key}?session=${encodeURIComponent(sessionId)}`;
|
|
3
7
|
}
|
|
@@ -9,32 +13,25 @@ export function replacePage(state, page) {
|
|
|
9
13
|
export function newestComments(comments) {
|
|
10
14
|
return [...(comments || [])].sort((a, b) => (b.updatedAt || b.createdAt || 0) - (a.updatedAt || a.createdAt || 0));
|
|
11
15
|
}
|
|
12
|
-
export function modePresentation(mode) {
|
|
16
|
+
export function modePresentation(mode = "view") {
|
|
13
17
|
return mode === "edit"
|
|
14
18
|
? { label: "Edit", icon: "pencil", description: "Direct editing on" }
|
|
15
19
|
: { label: "View", icon: "eye", description: "Editing off, comments enabled" };
|
|
16
20
|
}
|
|
17
21
|
export function createCommentUi() {
|
|
18
|
-
return {
|
|
19
|
-
}
|
|
20
|
-
export function ownMenu(ui, commentId, surface, triggerId) {
|
|
21
|
-
if (ui.edit)
|
|
22
|
-
return false;
|
|
23
|
-
ui.menu = { commentId, surface, triggerId };
|
|
24
|
-
ui.confirmation = null;
|
|
25
|
-
return true;
|
|
22
|
+
return { confirmation: null, edit: null };
|
|
26
23
|
}
|
|
27
24
|
export function ownConfirmation(ui, commentId, surface) {
|
|
28
|
-
if (ui.edit)
|
|
25
|
+
if (ui.edit || ui.confirmation?.status === "deleting")
|
|
29
26
|
return false;
|
|
30
|
-
ui.menu = null;
|
|
31
27
|
ui.confirmation = { commentId, surface, status: "idle" };
|
|
32
28
|
return true;
|
|
33
29
|
}
|
|
34
30
|
export function ownEdit(ui, comment, surface) {
|
|
35
31
|
if (ui.edit)
|
|
36
32
|
return ui.edit.commentId === comment.id;
|
|
37
|
-
ui.
|
|
33
|
+
if (ui.confirmation?.status === "deleting")
|
|
34
|
+
return false;
|
|
38
35
|
ui.confirmation = null;
|
|
39
36
|
ui.edit = {
|
|
40
37
|
commentId: comment.id,
|
|
@@ -54,7 +51,7 @@ export function clearOwned(ui, kind) {
|
|
|
54
51
|
return ui;
|
|
55
52
|
}
|
|
56
53
|
export function migrateCommentUi(ui, fromId, toId) {
|
|
57
|
-
for (const key of ["
|
|
54
|
+
for (const key of ["confirmation", "edit"]) {
|
|
58
55
|
if (ui[key]?.commentId === fromId)
|
|
59
56
|
ui[key].commentId = toId;
|
|
60
57
|
}
|
|
@@ -63,7 +60,7 @@ export function migrateCommentUi(ui, fromId, toId) {
|
|
|
63
60
|
export function reconcileCommentUi(ui, comments) {
|
|
64
61
|
const ids = new Set((comments || []).map((comment) => comment.id));
|
|
65
62
|
const removed = new Set();
|
|
66
|
-
for (const key of ["
|
|
63
|
+
for (const key of ["confirmation", "edit"]) {
|
|
67
64
|
if (ui[key] && !ids.has(ui[key].commentId)) {
|
|
68
65
|
removed.add(ui[key].commentId);
|
|
69
66
|
ui[key] = null;
|
package/lib/chrome.html
CHANGED
|
@@ -8,186 +8,43 @@
|
|
|
8
8
|
</head>
|
|
9
9
|
<body data-session="__SESSION_ID__" data-token="__TOKEN__">
|
|
10
10
|
<div class="app">
|
|
11
|
-
<header class="toolbar" aria-label="Review controls">
|
|
12
|
-
<div
|
|
13
|
-
<button type="button" id="latestVersion" class="toolbar-btn" aria-pressed="true" aria-controls="frame">Review</button>
|
|
14
|
-
<button type="button" id="seeChanges" class="toolbar-btn" aria-pressed="false" aria-controls="historyPanel">Changes</button>
|
|
15
|
-
</div>
|
|
16
|
-
<div class="page-controls" role="group" aria-label="Page controls">
|
|
17
|
-
<div class="mode-control">
|
|
18
|
-
<button type="button" id="modeButton" class="toolbar-btn" aria-haspopup="menu" aria-expanded="false">
|
|
19
|
-
<span id="modeIcon"></span><span id="modeLabel">View</span><span id="modeChevron"></span>
|
|
20
|
-
</button>
|
|
21
|
-
<div id="modeMenu" class="mode-menu" role="menu" hidden>
|
|
22
|
-
<button type="button" role="menuitemradio" aria-checked="true" data-mode="view">
|
|
23
|
-
<span data-icon="eye"></span><span><strong>View</strong><small>Editing off, comments enabled</small></span><span class="menu-check"></span>
|
|
24
|
-
</button>
|
|
25
|
-
<button type="button" role="menuitemradio" aria-checked="false" data-mode="edit">
|
|
26
|
-
<span data-icon="pencil"></span><span><strong>Edit</strong><small>Direct editing on</small></span><span class="menu-check"></span>
|
|
27
|
-
</button>
|
|
28
|
-
</div>
|
|
29
|
-
</div>
|
|
30
|
-
</div>
|
|
31
|
-
<div class="toolbar-actions">
|
|
32
|
-
<div id="documentTrustControls" hidden></div>
|
|
33
|
-
<button type="button" id="commentsButton" class="toolbar-btn" aria-controls="drawer" aria-label="Comments" aria-expanded="false">
|
|
34
|
-
<span data-icon="messages"></span><span class="toolbar-label">Comments</span><span id="toolbarCount" class="count-badge">0</span>
|
|
35
|
-
</button>
|
|
36
|
-
<details id="reviewDetails" class="review-details">
|
|
37
|
-
<summary>More <span aria-hidden="true">⌄</span></summary>
|
|
38
|
-
<div class="review-details-menu">
|
|
39
|
-
<p class="section">Page recovery</p>
|
|
40
|
-
<button type="button" id="executionStatic">Reload without scripts</button>
|
|
41
|
-
<button type="button" id="executionAuto" hidden>Use page interactions</button>
|
|
42
|
-
<p id="executionDetails" class="history-context" hidden></p>
|
|
43
|
-
</div>
|
|
44
|
-
</details>
|
|
45
|
-
</div>
|
|
11
|
+
<header class="toolbar shell-toolbar review-ui" aria-label="Review controls">
|
|
12
|
+
<div id="toolbarRoot"></div>
|
|
46
13
|
</header>
|
|
47
14
|
<main class="stage">
|
|
48
|
-
<
|
|
49
|
-
<
|
|
50
|
-
<p id="reloadMessage"></p>
|
|
51
|
-
<button type="button" id="safeReload" class="btn-primary">Reload latest; keep comment drafts</button>
|
|
52
|
-
<button type="button" id="keepCurrent" class="btn-ghost">Keep this page</button>
|
|
53
|
-
</section>
|
|
15
|
+
<div id="noticesRoot" class="review-ui"></div>
|
|
16
|
+
<div class="document-host">
|
|
54
17
|
<iframe
|
|
55
18
|
id="frame"
|
|
56
19
|
title="Artifact under review"
|
|
57
20
|
sandbox="allow-scripts allow-forms allow-modals"></iframe>
|
|
58
|
-
<section id="historyPanel" class="history-panel" aria-labelledby="historyTitle" hidden>
|
|
21
|
+
<section id="historyPanel" class="history-panel review-ui" aria-labelledby="historyTitle" hidden>
|
|
59
22
|
<h1 id="historyTitle" class="sr-only">Changes</h1>
|
|
60
|
-
<div class="
|
|
61
|
-
|
|
62
|
-
<
|
|
63
|
-
<div id="comparisonModes" role="group" aria-label="Comparison format"></div>
|
|
64
|
-
<button type="button" id="captureResult" class="btn-primary" hidden>Capture result</button>
|
|
65
|
-
</div>
|
|
66
|
-
<div class="history-summary">
|
|
67
|
-
<p id="historyStatus" role="status">No review rounds yet. Send feedback to start a comparison.</p>
|
|
68
|
-
<div id="historyCounts" class="history-counts"></div>
|
|
69
|
-
</div>
|
|
70
|
-
<p id="historyError" class="compose-error" role="alert" hidden></p>
|
|
71
|
-
<div class="comparison-header">
|
|
72
|
-
<nav id="changeNavigation" class="history-navigation" aria-label="Change navigation" hidden>
|
|
73
|
-
<button type="button" id="previousChange" class="btn-ghost" aria-label="Previous change">Previous</button>
|
|
74
|
-
<span id="changePosition"></span>
|
|
75
|
-
<label class="change-jump-label"><span>Jump to</span> <select id="changeJump" aria-label="Jump to change"></select></label>
|
|
76
|
-
<button type="button" id="nextChange" class="btn-ghost" aria-label="Next change">Next</button>
|
|
77
|
-
</nav>
|
|
23
|
+
<div id="changesControlsRoot" class="review-ui"></div>
|
|
24
|
+
<div id="comparisonHeader" class="comparison-header">
|
|
25
|
+
<div id="changesNavigationRoot" class="review-ui"></div>
|
|
78
26
|
<div class="comparison-heading-slot"></div>
|
|
79
27
|
</div>
|
|
80
28
|
<article id="changeDetail" class="change-detail" aria-label="Continuous before and after comparison"></article>
|
|
81
|
-
<
|
|
82
|
-
<summary>Comparison details</summary>
|
|
83
|
-
<p id="historyCurrentStatus" class="history-context" hidden></p>
|
|
84
|
-
<p id="historyViewCoverage" class="history-context" hidden></p>
|
|
85
|
-
<div id="historyUnavailable" class="history-context" hidden></div>
|
|
86
|
-
<p>Content is a readable reconstruction of saved blocks, not the original page layout. Historical scripts, authored styles and image URLs are never replayed. Table rows use saved cell selectors; complex spanning structure is shown in row order. Source shows saved file text.</p>
|
|
87
|
-
<dl id="historyTiming" class="history-timing" hidden></dl>
|
|
88
|
-
<p id="historyCaptureDelay" class="history-context" hidden></p>
|
|
89
|
-
<details id="historyLimitations" hidden>
|
|
90
|
-
<summary>Comparison coverage and limits</summary>
|
|
91
|
-
<ul id="historyLimitationsList"></ul>
|
|
92
|
-
</details>
|
|
93
|
-
<button type="button" id="finishCapture" class="btn-ghost" hidden>Finish with available snapshots</button>
|
|
94
|
-
<p id="finishCaptureHelp" class="history-context" hidden>Finishing marks missing Content as unavailable; it does not invent a result. Existing Source comparisons remain available, and this target will no longer retry capture.</p>
|
|
95
|
-
</details>
|
|
29
|
+
<div id="changesDiagnosticsRoot" class="review-ui"></div>
|
|
96
30
|
</section>
|
|
97
|
-
</main>
|
|
98
|
-
|
|
99
|
-
<section id="compose" class="contextual-compose" role="dialog" aria-modal="false" aria-labelledby="composeTitle" hidden>
|
|
100
|
-
<div class="compose-head">
|
|
101
|
-
<div>
|
|
102
|
-
<span class="chip" id="composeKind">Selection</span>
|
|
103
|
-
<strong id="composeTitle">Add comment</strong>
|
|
104
|
-
</div>
|
|
105
|
-
<button type="button" id="composeClose" class="icon-btn" title="Cancel comment" aria-label="Cancel comment"></button>
|
|
106
31
|
</div>
|
|
107
|
-
|
|
108
|
-
<span id="composeDirectionText"></span>
|
|
109
|
-
<button type="button" id="composeReveal" class="link-action">Back to selection</button>
|
|
110
|
-
</div>
|
|
111
|
-
<p class="quote" id="composeQuote"></p>
|
|
112
|
-
<textarea id="composeText" rows="3" placeholder="What should change?" aria-label="Comment" aria-describedby="composeHelp"></textarea>
|
|
113
|
-
<p id="composeHelp" class="compose-help"><span>Enter to comment</span><span>Shift+Enter for new line</span></p>
|
|
114
|
-
<p id="composeError" class="compose-error" role="alert" hidden></p>
|
|
115
|
-
<div class="compose-actions">
|
|
116
|
-
<button type="button" id="composeAdd" class="btn-primary"><span data-icon="messageSquarePlus"></span><span id="composeAddLabel">Comment</span></button>
|
|
117
|
-
<button type="button" id="composeCancel" class="btn-ghost">Cancel</button>
|
|
118
|
-
</div>
|
|
119
|
-
</section>
|
|
120
|
-
|
|
121
|
-
<article id="alignedCard" class="aligned-card" hidden></article>
|
|
122
|
-
|
|
123
|
-
<aside class="drawer" id="drawer" aria-label="Review feedback" aria-hidden="true">
|
|
124
|
-
<div class="drawer-head">
|
|
125
|
-
<div>
|
|
126
|
-
<span class="section">Review</span>
|
|
127
|
-
<h2 id="drawerTitle">Comments</h2>
|
|
128
|
-
</div>
|
|
129
|
-
<button type="button" id="theme" class="icon-btn" title="Switch chrome to dark" aria-label="Switch chrome to dark"></button>
|
|
130
|
-
<button type="button" id="drawerClose" class="icon-btn" title="Close review drawer" aria-label="Close review drawer"></button>
|
|
131
|
-
</div>
|
|
132
|
-
<div id="commentsSection" class="drawer-inventory" tabindex="-1" aria-labelledby="inventoryHeading">
|
|
133
|
-
<section>
|
|
134
|
-
<div class="rail-head">
|
|
135
|
-
<span class="section" id="inventoryHeading">Comments</span>
|
|
136
|
-
<span class="dim" id="count">0</span>
|
|
137
|
-
</div>
|
|
138
|
-
|
|
139
|
-
<div id="cards" class="cards"></div>
|
|
32
|
+
</main>
|
|
140
33
|
|
|
141
|
-
|
|
142
|
-
</section>
|
|
34
|
+
<section id="compose" class="contextual-compose review-ui" role="dialog" aria-modal="false" aria-labelledby="composeTitle" hidden></section>
|
|
143
35
|
|
|
144
|
-
|
|
145
|
-
<div class="rail-head tight">
|
|
146
|
-
<span class="section">Other pages</span>
|
|
147
|
-
<span class="dim" id="othersCount">0</span>
|
|
148
|
-
</div>
|
|
149
|
-
<div class="edit-list" id="othersList"></div>
|
|
150
|
-
</section>
|
|
36
|
+
<div id="alignedCard" class="aligned-card review-ui" hidden></div>
|
|
151
37
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
</div>
|
|
158
|
-
<div class="save-line" id="saveLine"><span class="dot"></span><span id="saveText"></span></div>
|
|
159
|
-
<div class="edit-list" id="editList"></div>
|
|
160
|
-
</section>
|
|
38
|
+
<aside class="drawer" id="drawer" aria-label="Feedback" aria-hidden="true">
|
|
39
|
+
<div id="drawerHeadRoot" class="review-ui"></div>
|
|
40
|
+
<div id="commentsSection" class="drawer-inventory" tabindex="-1" aria-label="Feedback items">
|
|
41
|
+
<div id="inventoryRoot" class="review-ui"></div>
|
|
42
|
+
<div id="editsRoot" class="review-ui"></div>
|
|
161
43
|
|
|
162
44
|
</div>
|
|
163
|
-
<footer id="sendSection" class="drawer-footer send-section" tabindex="-1" aria-label="Send feedback">
|
|
164
|
-
<div class="send-primary">
|
|
165
|
-
<div class="rail-head tight">
|
|
166
|
-
<span class="section">Send feedback</span>
|
|
167
|
-
</div>
|
|
168
|
-
<textarea id="note" rows="2" placeholder="Overall note…"></textarea>
|
|
169
|
-
<button type="button" id="send" class="send" disabled>Nothing to send yet</button>
|
|
170
|
-
<p id="draftWarning" class="dim" hidden></p>
|
|
171
|
-
<div id="captureWarning" class="capture-warning" role="alert" hidden>
|
|
172
|
-
<p id="captureWarningText"></p>
|
|
173
|
-
<div id="captureTargets"></div>
|
|
174
|
-
<button type="button" id="recaptureBaseline" class="btn-primary">Recapture and send</button>
|
|
175
|
-
<button type="button" id="sendWithoutComparison" class="btn-ghost">Send without comparison</button>
|
|
176
|
-
</div>
|
|
177
|
-
<div class="agent-line" id="agentLine" hidden><span class="dot amber"></span><span id="agentText"></span></div>
|
|
178
|
-
</div>
|
|
179
|
-
<div class="send-secondary">
|
|
180
|
-
<div class="handoff" id="handoff" hidden>
|
|
181
|
-
<p class="handoff-title">Sent — but no agent is listening yet.</p>
|
|
182
|
-
<p class="handoff-body">Paste this into your agent — Claude Code, Codex, or Cursor:</p>
|
|
183
|
-
<div class="handoff-cmd"><code id="handoffCmd"></code></div>
|
|
184
|
-
<button type="button" id="handoffCopy" class="btn-ghost">Copy prompt</button>
|
|
185
|
-
</div>
|
|
186
|
-
<button type="button" id="endReview" class="end-review" title="Stop this review and release the waiting agent">End review</button>
|
|
187
|
-
</div>
|
|
188
|
-
</footer>
|
|
45
|
+
<footer id="sendSection" class="drawer-footer send-section review-ui" tabindex="-1" aria-label="Send feedback"></footer>
|
|
189
46
|
</aside>
|
|
190
|
-
<div id="
|
|
47
|
+
<div id="drawerBackdropRoot"></div>
|
|
191
48
|
<div id="liveRegion" class="sr-only" role="status" aria-live="polite"></div>
|
|
192
49
|
</div>
|
|
193
50
|
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { createControllerStore } from "./controller-store.js";
|
|
2
|
+
import { newestComments } from "./chrome-session.js";
|
|
3
|
+
function age(timestamp) {
|
|
4
|
+
const seconds = Math.max(0, Math.round((Date.now() - (timestamp ?? Date.now())) / 1000));
|
|
5
|
+
if (seconds < 45)
|
|
6
|
+
return "just now";
|
|
7
|
+
const minutes = Math.round(seconds / 60);
|
|
8
|
+
if (minutes < 60)
|
|
9
|
+
return `${minutes}m`;
|
|
10
|
+
const hours = Math.round(minutes / 60);
|
|
11
|
+
return hours < 24 ? `${hours}h` : `${Math.round(hours / 24)}d`;
|
|
12
|
+
}
|
|
13
|
+
export function createCommentsController(options) {
|
|
14
|
+
let disposed = false;
|
|
15
|
+
let sectionOpen = true;
|
|
16
|
+
const store = createControllerStore(() => {
|
|
17
|
+
const state = options.read();
|
|
18
|
+
return {
|
|
19
|
+
open: state.open && !state.comparing,
|
|
20
|
+
sectionOpen,
|
|
21
|
+
sectionLock: state.ui.edit ? "Save or cancel the comment edit to collapse."
|
|
22
|
+
: state.ui.confirmation ? "Finish or cancel the deletion to collapse." : "",
|
|
23
|
+
activeId: state.activeId,
|
|
24
|
+
disabled: state.ended,
|
|
25
|
+
loading: !state.hasPage && !state.error && !state.ended,
|
|
26
|
+
error: !state.hasPage ? state.error : null,
|
|
27
|
+
showEmpty: state.hasPage && state.comments.length === 0 && !state.composeOpen,
|
|
28
|
+
cards: newestComments(state.comments).map((comment) => ({
|
|
29
|
+
id: comment.id, quote: comment.quote, feedback: comment.feedback,
|
|
30
|
+
age: age(comment.updatedAt || comment.createdAt),
|
|
31
|
+
correction: !!comment.correction, edited: !!comment.updatedAt,
|
|
32
|
+
orphaned: state.orphans.has(comment.id), active: state.activeId === comment.id,
|
|
33
|
+
})),
|
|
34
|
+
others: state.others.map(({ key, filename, count }) => ({ key, filename, count })),
|
|
35
|
+
ui: state.ui,
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
const available = () => !disposed && !options.read().ended && !options.read().comparing;
|
|
39
|
+
const has = (id) => available() && options.read().comments.some((comment) => comment.id === id);
|
|
40
|
+
const deleting = () => options.read().ui.confirmation?.status === "deleting";
|
|
41
|
+
const editable = (id) => has(id) && !deleting() && (!options.read().ui.edit || options.read().ui.edit?.commentId === id);
|
|
42
|
+
const ownsEdit = (id) => has(id) && options.read().ui.edit?.commentId === id;
|
|
43
|
+
const publish = () => {
|
|
44
|
+
if (disposed)
|
|
45
|
+
return;
|
|
46
|
+
const state = options.read();
|
|
47
|
+
if (state.ui.edit || state.ui.confirmation)
|
|
48
|
+
sectionOpen = true;
|
|
49
|
+
store.publish();
|
|
50
|
+
};
|
|
51
|
+
return {
|
|
52
|
+
getSnapshot: store.getSnapshot, subscribe: store.subscribe, publish,
|
|
53
|
+
commands: {
|
|
54
|
+
setSectionOpen(open) {
|
|
55
|
+
const state = options.read();
|
|
56
|
+
if (!available() || (!open && (state.ui.edit || state.ui.confirmation)))
|
|
57
|
+
return;
|
|
58
|
+
sectionOpen = open;
|
|
59
|
+
publish();
|
|
60
|
+
},
|
|
61
|
+
close() { if (available())
|
|
62
|
+
options.close(); },
|
|
63
|
+
activate(id, scroll) { if (editable(id))
|
|
64
|
+
options.activate(id, scroll); },
|
|
65
|
+
edit(id, surface = "drawer") {
|
|
66
|
+
if (editable(id)) {
|
|
67
|
+
sectionOpen = true;
|
|
68
|
+
options.edit(id, surface);
|
|
69
|
+
publish();
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
dismiss() { if (available())
|
|
73
|
+
options.dismiss?.(); },
|
|
74
|
+
updateEdit(id, value) {
|
|
75
|
+
if (!ownsEdit(id))
|
|
76
|
+
return;
|
|
77
|
+
const edit = options.read().ui.edit;
|
|
78
|
+
if (!edit || edit.status === "saving")
|
|
79
|
+
return;
|
|
80
|
+
Object.assign(edit, value, { validation: value.draft === edit.draft ? edit.validation : "" });
|
|
81
|
+
publish();
|
|
82
|
+
},
|
|
83
|
+
save(id) { if (ownsEdit(id) && !options.read().ui.edit?.composing)
|
|
84
|
+
return options.save(); },
|
|
85
|
+
cancelEdit(id) { if (ownsEdit(id))
|
|
86
|
+
options.cancelEdit(); },
|
|
87
|
+
confirm(id, surface = "drawer") {
|
|
88
|
+
if (has(id) && !options.read().ui.edit && !deleting()) {
|
|
89
|
+
sectionOpen = true;
|
|
90
|
+
options.confirm(id, surface);
|
|
91
|
+
publish();
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
cancelDelete(id) {
|
|
95
|
+
if (has(id) && !deleting() && options.read().ui.confirmation?.commentId === id)
|
|
96
|
+
options.cancelDelete();
|
|
97
|
+
},
|
|
98
|
+
remove(id) {
|
|
99
|
+
if (has(id) && options.read().ui.confirmation?.commentId === id)
|
|
100
|
+
return options.remove(id);
|
|
101
|
+
},
|
|
102
|
+
navigate(key) {
|
|
103
|
+
if (available() && options.read().others.some((page) => page.key === key))
|
|
104
|
+
return options.navigate(key);
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
dispose() { disposed = true; store.dispose(); },
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createControllerStore } from "./controller-store.js";
|
|
2
|
+
export function createComposerDraft() {
|
|
3
|
+
return { text: "", selectionStart: 0, selectionEnd: 0, composing: false, error: "", retry: false };
|
|
4
|
+
}
|
|
5
|
+
export function createContextualController(options) {
|
|
6
|
+
let disposed = false;
|
|
7
|
+
const store = createControllerStore(options.read);
|
|
8
|
+
const available = () => !disposed && options.read().open && !options.read().disabled;
|
|
9
|
+
return {
|
|
10
|
+
getSnapshot: store.getSnapshot, subscribe: store.subscribe, publish: store.publish,
|
|
11
|
+
commands: {
|
|
12
|
+
update(value) {
|
|
13
|
+
if (!available() || options.read().submitting)
|
|
14
|
+
return;
|
|
15
|
+
Object.assign(options.read().draft, value);
|
|
16
|
+
store.publish();
|
|
17
|
+
},
|
|
18
|
+
submit() {
|
|
19
|
+
if (available() && !options.read().draft.composing)
|
|
20
|
+
return options.submit();
|
|
21
|
+
},
|
|
22
|
+
cancel() { if (available() && !options.read().submitting)
|
|
23
|
+
return options.cancel(); },
|
|
24
|
+
reveal() { if (available())
|
|
25
|
+
options.reveal(); },
|
|
26
|
+
focus() { if (available())
|
|
27
|
+
options.focus(); },
|
|
28
|
+
measure() { if (!disposed)
|
|
29
|
+
options.measure(); },
|
|
30
|
+
},
|
|
31
|
+
dispose() { disposed = true; store.dispose(); },
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capture small controller projections, not entire history/comparison payloads.
|
|
3
|
+
* Inputs are plain records/arrays/scalars; project mutable maps into entries first.
|
|
4
|
+
* Only publish reads the owner. Reads and unchanged snapshot branches are cached.
|
|
5
|
+
* Subscription cleanup does not dispose the owner. Disposal retains the final
|
|
6
|
+
* snapshot, stops publication, and rejects new subscriptions.
|
|
7
|
+
*/
|
|
8
|
+
export function createControllerStore(read) {
|
|
9
|
+
const owned = new WeakSet();
|
|
10
|
+
function capture(value, previous, ancestors = new Set()) {
|
|
11
|
+
if (value === null || typeof value !== "object") {
|
|
12
|
+
if (typeof value === "function")
|
|
13
|
+
throw new TypeError("Controller snapshots cannot contain functions");
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
if (owned.has(value))
|
|
17
|
+
return value;
|
|
18
|
+
const array = Array.isArray(value);
|
|
19
|
+
const prototype = Object.getPrototypeOf(value);
|
|
20
|
+
if (!array && prototype !== Object.prototype && prototype !== null) {
|
|
21
|
+
throw new TypeError("Controller snapshots require plain records and arrays; project mutable collections first");
|
|
22
|
+
}
|
|
23
|
+
if (ancestors.has(value))
|
|
24
|
+
throw new TypeError("Controller snapshots cannot contain cycles");
|
|
25
|
+
ancestors.add(value);
|
|
26
|
+
try {
|
|
27
|
+
const keys = Reflect.ownKeys(value);
|
|
28
|
+
const prior = previous !== null && typeof previous === "object" &&
|
|
29
|
+
Array.isArray(previous) === array && Object.getPrototypeOf(previous) === prototype
|
|
30
|
+
? previous : undefined;
|
|
31
|
+
let unchanged = prior !== undefined && Reflect.ownKeys(prior).length === keys.length;
|
|
32
|
+
const next = array
|
|
33
|
+
? [] : Object.create(prototype);
|
|
34
|
+
for (const key of keys) {
|
|
35
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
36
|
+
if (!("value" in descriptor))
|
|
37
|
+
throw new TypeError("Controller snapshots cannot contain accessors");
|
|
38
|
+
const before = prior && Object.getOwnPropertyDescriptor(prior, key);
|
|
39
|
+
const child = capture(descriptor.value, before?.value, ancestors);
|
|
40
|
+
if (!before || !Object.is(child, before.value))
|
|
41
|
+
unchanged = false;
|
|
42
|
+
Object.defineProperty(next, key, {
|
|
43
|
+
value: child, enumerable: descriptor.enumerable, writable: true,
|
|
44
|
+
configurable: key !== "length" || !array,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
if (unchanged)
|
|
48
|
+
return prior;
|
|
49
|
+
Object.freeze(next);
|
|
50
|
+
owned.add(next);
|
|
51
|
+
return next;
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
ancestors.delete(value);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
let snapshot = capture(read(), undefined);
|
|
58
|
+
let disposed = false;
|
|
59
|
+
const subscriptions = new Set();
|
|
60
|
+
return {
|
|
61
|
+
getSnapshot: () => snapshot,
|
|
62
|
+
subscribe(listener) {
|
|
63
|
+
if (disposed)
|
|
64
|
+
throw new Error("Controller store is disposed");
|
|
65
|
+
const subscription = { listener };
|
|
66
|
+
subscriptions.add(subscription);
|
|
67
|
+
return () => { subscriptions.delete(subscription); };
|
|
68
|
+
},
|
|
69
|
+
publish() {
|
|
70
|
+
if (disposed)
|
|
71
|
+
return false;
|
|
72
|
+
const next = capture(read(), snapshot);
|
|
73
|
+
if (Object.is(next, snapshot))
|
|
74
|
+
return false;
|
|
75
|
+
snapshot = next;
|
|
76
|
+
const errors = [];
|
|
77
|
+
for (const subscription of [...subscriptions]) {
|
|
78
|
+
if (!subscriptions.has(subscription))
|
|
79
|
+
continue;
|
|
80
|
+
try {
|
|
81
|
+
subscription.listener();
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
errors.push(error);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (errors.length)
|
|
88
|
+
throw new AggregateError(errors, "Controller subscription failed");
|
|
89
|
+
return true;
|
|
90
|
+
},
|
|
91
|
+
dispose() {
|
|
92
|
+
disposed = true;
|
|
93
|
+
subscriptions.clear();
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
package/lib/execution-client.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** UI policy
|
|
1
|
+
/** UI policy describes the visible frame, not a newer on-disk source. */
|
|
2
2
|
export function executionPresentation(page, rendered, { loading = false, pendingReload = false, error = null } = {}) {
|
|
3
3
|
const eligible = page?.kind === "file" && !page.markdown;
|
|
4
4
|
const policy = rendered?.savePolicy;
|
|
@@ -17,53 +17,3 @@ export function executionPresentation(page, rendered, { loading = false, pending
|
|
|
17
17
|
].filter(Boolean).join(" "),
|
|
18
18
|
};
|
|
19
19
|
}
|
|
20
|
-
export function createExecutionControls({ api, sessionId, elements, changed, failed }) {
|
|
21
|
-
let busy = false;
|
|
22
|
-
let currentPage = null;
|
|
23
|
-
async function recover(preference) {
|
|
24
|
-
if (busy || !currentPage)
|
|
25
|
-
return;
|
|
26
|
-
const key = currentPage.key;
|
|
27
|
-
busy = true;
|
|
28
|
-
if (elements.menu) {
|
|
29
|
-
elements.menu.open = false;
|
|
30
|
-
elements.menu.querySelector("summary")?.focus({ preventScroll: true });
|
|
31
|
-
}
|
|
32
|
-
elements.static.disabled = elements.auto.disabled = true;
|
|
33
|
-
try {
|
|
34
|
-
const result = await api(`/api/session/${sessionId}/execution`, {
|
|
35
|
-
method: "POST", body: JSON.stringify({ key, preference }),
|
|
36
|
-
});
|
|
37
|
-
if (currentPage?.key === key)
|
|
38
|
-
changed(result.page);
|
|
39
|
-
// The server's reload event owns draft-safe frame replacement.
|
|
40
|
-
}
|
|
41
|
-
catch (error) {
|
|
42
|
-
failed(error);
|
|
43
|
-
}
|
|
44
|
-
finally {
|
|
45
|
-
busy = false;
|
|
46
|
-
elements.static.disabled = elements.auto.disabled = false;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
elements.static.addEventListener("click", () => void recover("static"));
|
|
50
|
-
elements.auto.addEventListener("click", () => void recover("auto"));
|
|
51
|
-
return {
|
|
52
|
-
render(page, rendered, options) {
|
|
53
|
-
currentPage = page;
|
|
54
|
-
const view = executionPresentation(page, rendered, options);
|
|
55
|
-
elements.static.hidden = !view.eligible || view.preference === "static";
|
|
56
|
-
elements.auto.hidden = !view.eligible || view.preference !== "static";
|
|
57
|
-
if (elements.menu)
|
|
58
|
-
elements.menu.hidden = !view.eligible && !view.detail;
|
|
59
|
-
elements.status.textContent = view.status;
|
|
60
|
-
elements.status.hidden = !view.status;
|
|
61
|
-
if (elements.details) {
|
|
62
|
-
elements.details.textContent = view.detail;
|
|
63
|
-
elements.details.hidden = !view.detail;
|
|
64
|
-
}
|
|
65
|
-
if (elements.editDescription)
|
|
66
|
-
elements.editDescription.textContent = view.editDescription;
|
|
67
|
-
},
|
|
68
|
-
};
|
|
69
|
-
}
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { record } from "./chrome-api.js";
|
|
2
|
+
import { createControllerStore } from "./controller-store.js";
|
|
2
3
|
import { deliverFeedback } from "./history-coordinator.js";
|
|
3
4
|
export function createFeedbackController(options) {
|
|
4
|
-
const listeners = new Set();
|
|
5
5
|
let state = { phase: "idle" };
|
|
6
6
|
let disposed = false;
|
|
7
7
|
let inFlight = false;
|
|
8
8
|
let sent = false;
|
|
9
|
+
const store = createControllerStore(() => ({ ...state, sending: inFlight, sent }));
|
|
9
10
|
const busy = () => inFlight;
|
|
10
11
|
const publish = () => { if (!disposed)
|
|
11
|
-
|
|
12
|
-
listener(); };
|
|
12
|
+
store.publish(); };
|
|
13
13
|
const current = (identity) => !disposed && !!identity.renderId &&
|
|
14
14
|
identity.key === options.current().key && identity.generation === options.current().generation &&
|
|
15
15
|
identity.renderId === options.current().renderId;
|
|
@@ -17,14 +17,12 @@ export function createFeedbackController(options) {
|
|
|
17
17
|
get state() { return state; },
|
|
18
18
|
get sending() { return busy(); },
|
|
19
19
|
get sent() { return sent; },
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return () => { listeners.delete(listener); };
|
|
23
|
-
},
|
|
20
|
+
getSnapshot: store.getSnapshot,
|
|
21
|
+
subscribe: store.subscribe,
|
|
24
22
|
clearSent() { sent = false; if (!busy())
|
|
25
|
-
state = { phase: "idle" }; },
|
|
23
|
+
state = { phase: "idle" }; publish(); },
|
|
26
24
|
async send() {
|
|
27
|
-
if (busy() || disposed)
|
|
25
|
+
if (busy() || disposed || sent)
|
|
28
26
|
return;
|
|
29
27
|
const identity = options.current();
|
|
30
28
|
const sentNote = options.note();
|
|
@@ -73,15 +71,24 @@ export function createFeedbackController(options) {
|
|
|
73
71
|
state = { phase: "delivered" };
|
|
74
72
|
if (!disposed)
|
|
75
73
|
options.clearNote(sentNote);
|
|
74
|
+
publish();
|
|
76
75
|
},
|
|
77
76
|
refresh: () => disposed ? Promise.resolve(false) : options.refresh(),
|
|
78
77
|
});
|
|
79
78
|
if (disposed)
|
|
80
79
|
return;
|
|
81
|
-
if (outcome.refreshFailure)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
80
|
+
if (outcome.refreshFailure) {
|
|
81
|
+
const notice = "Feedback sent. History could not be refreshed; your feedback does not need to be sent again.";
|
|
82
|
+
if (sent)
|
|
83
|
+
state = { phase: "delivered", notice };
|
|
84
|
+
options.warning(notice);
|
|
85
|
+
}
|
|
86
|
+
else if (outcome.captureFailure) {
|
|
87
|
+
const notice = "Feedback sent. Content comparison may be incomplete.";
|
|
88
|
+
if (sent)
|
|
89
|
+
state = { phase: "delivered", notice };
|
|
90
|
+
options.announce(notice);
|
|
91
|
+
}
|
|
85
92
|
}
|
|
86
93
|
catch (error) {
|
|
87
94
|
if (disposed)
|
|
@@ -101,6 +108,6 @@ export function createFeedbackController(options) {
|
|
|
101
108
|
options.resumeCapture();
|
|
102
109
|
}
|
|
103
110
|
},
|
|
104
|
-
dispose() { disposed = true;
|
|
111
|
+
dispose() { disposed = true; store.dispose(); },
|
|
105
112
|
};
|
|
106
113
|
}
|