@m13v/s4l 1.7.4-rc.4 → 1.7.4
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 +134 -101
- package/bin/cli.js +23 -0
- package/mcp/dist/index.js +24 -5
- package/mcp/dist/runtime.js +110 -0
- package/mcp/dist/version.js +163 -54
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_card.py +55 -19
- package/mcp/menubar/s4l_menubar.py +179 -56
- package/mcp/menubar/s4l_state.py +41 -1
- package/mcp/package.json +1 -1
- package/package.json +4 -2
- package/scripts/active_experiments.py +30 -6
- package/scripts/autopilot_stall_watch.py +185 -63
- package/scripts/cdp_ready_check.py +94 -0
- package/scripts/engage_twitter_helper.py +83 -17
- package/scripts/engagement_styles.py +221 -26
- package/scripts/enrich_reply_parents.py +68 -22
- package/scripts/feedback_digest.py +24 -1
- package/scripts/generate_daily_human_style.py +31 -7
- package/scripts/invent_styles.py +103 -26
- package/scripts/linkedin_cadence.py +234 -0
- package/scripts/linkedin_killswitch.py +43 -0
- package/scripts/memory_snapshot.py +57 -0
- package/scripts/patches/browser-harness-loopback-cdp-proxy.patch +118 -0
- package/scripts/recent_self_posts.py +6 -0
- package/scripts/reddit_browser.py +57 -3
- package/scripts/s4l_box_update.sh +20 -1
- package/scripts/s4l_mode.py +3 -4
- package/scripts/scan_x_profile.py +25 -7
- package/scripts/schedule_state.py +23 -0
- package/scripts/scheduled_task_selfheal.py +213 -16
- package/scripts/setup_twitter_auth.py +6 -1
- package/scripts/snapshot.py +179 -61
- package/scripts/stall_guard.py +70 -0
- package/scripts/test_scheduled_task_selfheal.py +121 -0
- package/scripts/top_performers.py +34 -0
- package/scripts/twitter_browser.py +146 -8
- package/scripts/twitter_post_plan.py +10 -1
- package/scripts/twitter_scan.py +26 -0
- package/scripts/watchdog_hung_runs.py +117 -3
- package/skill/amplitude-24h-signups.sh +1 -1
- package/skill/audit-dm-staleness.sh +1 -1
- package/skill/audit-linkedin.sh +1 -1
- package/skill/audit-reddit-resurrect.sh +1 -1
- package/skill/audit.sh +5 -5
- package/skill/check-web-chats.sh +1 -1
- package/skill/dm-outreach-linkedin.sh +4 -4
- package/skill/dm-outreach-reddit.sh +1 -1
- package/skill/dm-outreach-twitter.sh +1 -1
- package/skill/engage-dm-replies.sh +16 -3
- package/skill/engage-linkedin.sh +2 -2
- package/skill/engage-moltbook.sh +1 -1
- package/skill/engage-twitter.sh +4 -4
- package/skill/github-engage.sh +1 -1
- package/skill/lib/browser-launch.sh +54 -0
- package/skill/lib/linkedin-backend.sh +22 -15
- package/skill/lib/reddit-backend.sh +17 -11
- package/skill/lib/twitter-backend.sh +137 -16
- package/skill/link-edit-github.sh +1 -1
- package/skill/link-edit-moltbook.sh +1 -1
- package/skill/link-edit-reddit.sh +1 -1
- package/skill/linkedin-cadence.sh +20 -0
- package/skill/linkedin-presence.sh +1 -1
- package/skill/lock.sh +30 -6
- package/skill/precompute-stats.sh +1 -1
- package/skill/refresh-instagram-tokens.sh +1 -1
- package/skill/refresh-twitter-following.sh +1 -1
- package/skill/run-generate-daily-style.sh +1 -1
- package/skill/run-instagram-daily.sh +1 -1
- package/skill/run-instagram-render.sh +1 -1
- package/skill/run-linkedin-launchd.sh +1 -1
- package/skill/run-linkedin-unipile.sh +1 -1
- package/skill/run-linkedin.sh +1 -1
- package/skill/run-reddit-search.sh +2 -2
- package/skill/run-reddit-threads.sh +1 -1
- package/skill/run-twitter-cycle.sh +190 -55
- package/skill/scan-instagram-replies.sh +1 -1
- package/skill/sentry-digest.sh +1 -1
- package/skill/stats-instagram.sh +1 -1
- package/skill/stats-linkedin.sh +2 -2
- package/skill/stats.sh +1 -1
- package/skill/sweep-link-clicks.sh +1 -1
- package/scripts/_lock_preempt_test.py +0 -60
- package/scripts/saps_activity.py +0 -18
- package/scripts/saps_mode.py +0 -18
package/mcp/dist/version.js
CHANGED
|
@@ -94,6 +94,67 @@ export function releaseChannel() {
|
|
|
94
94
|
}
|
|
95
95
|
return "stable";
|
|
96
96
|
}
|
|
97
|
+
const SHARED_TTL_S = 600; // banner latency ceiling: a new release surfaces within 10 min
|
|
98
|
+
const PROBE_LOCK_STALE_MS = 30_000;
|
|
99
|
+
function sharedCachePath() {
|
|
100
|
+
return path.join(STATE_DIR, "latest-release.json");
|
|
101
|
+
}
|
|
102
|
+
function readSharedCache(channel) {
|
|
103
|
+
try {
|
|
104
|
+
const d = JSON.parse(fs.readFileSync(sharedCachePath(), "utf-8"));
|
|
105
|
+
if (!d || d.channel !== channel || typeof d.at !== "number")
|
|
106
|
+
return null;
|
|
107
|
+
return {
|
|
108
|
+
at: d.at,
|
|
109
|
+
channel,
|
|
110
|
+
version: typeof d.version === "string" ? d.version : null,
|
|
111
|
+
tag: typeof d.tag === "string" ? d.tag : null,
|
|
112
|
+
etag: typeof d.etag === "string" ? d.etag : null,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function writeSharedCache(c) {
|
|
120
|
+
try {
|
|
121
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
122
|
+
const tmp = `${sharedCachePath()}.tmp.${process.pid}`;
|
|
123
|
+
fs.writeFileSync(tmp, JSON.stringify(c));
|
|
124
|
+
fs.renameSync(tmp, sharedCachePath());
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
/* best effort; worst case another process re-probes */
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// Single-flight guard so N concurrent processes with an expired shared cache
|
|
131
|
+
// don't all probe at once. acquired=false ONLY when another probe holds a
|
|
132
|
+
// FRESH lock; any other failure probes anyway rather than going blind.
|
|
133
|
+
function tryProbeLock() {
|
|
134
|
+
const lock = path.join(STATE_DIR, "latest-release.lock");
|
|
135
|
+
const create = () => fs.closeSync(fs.openSync(lock, "wx"));
|
|
136
|
+
try {
|
|
137
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
138
|
+
create();
|
|
139
|
+
return { acquired: true, lock };
|
|
140
|
+
}
|
|
141
|
+
catch (e) {
|
|
142
|
+
if (e.code === "EEXIST") {
|
|
143
|
+
try {
|
|
144
|
+
if (Date.now() - fs.statSync(lock).mtimeMs > PROBE_LOCK_STALE_MS) {
|
|
145
|
+
fs.unlinkSync(lock); // stale: holder died mid-probe
|
|
146
|
+
create();
|
|
147
|
+
return { acquired: true, lock };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
/* raced with the holder */
|
|
152
|
+
}
|
|
153
|
+
return { acquired: false, lock: null };
|
|
154
|
+
}
|
|
155
|
+
return { acquired: true, lock: null };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
97
158
|
function parseSemverish(v) {
|
|
98
159
|
return /^\d+\.\d+\.\d+/.test(v) ? v : null;
|
|
99
160
|
}
|
|
@@ -128,41 +189,45 @@ async function latestFromGithubRedirect() {
|
|
|
128
189
|
return null;
|
|
129
190
|
}
|
|
130
191
|
}
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
192
|
+
// Conditional-request state lives in the SHARED cache file (latest-release.json)
|
|
193
|
+
// so the ETag survives process boundaries: short-lived MCP respawns used to pay
|
|
194
|
+
// a full 200 per process; now every probe sends If-None-Match and gets a free
|
|
195
|
+
// 304 between releases.
|
|
196
|
+
async function curlConditional(url, etag) {
|
|
197
|
+
const args = ["-sS", "-m", "10", "-H", "Accept: application/vnd.github+json"];
|
|
198
|
+
if (etag)
|
|
199
|
+
args.push("-H", `If-None-Match: ${etag}`);
|
|
200
|
+
args.push("-w", "\n__CURL_STATUS__:%{http_code}\n__CURL_ETAG__:%header{etag}", url);
|
|
201
|
+
const res = await run("curl", args, { timeoutMs: 12000, noTee: true });
|
|
202
|
+
let status = 0;
|
|
203
|
+
let newEtag = null;
|
|
204
|
+
const body = [];
|
|
205
|
+
for (const line of (res.stdout || "").split("\n")) {
|
|
206
|
+
if (line.startsWith("__CURL_STATUS__:"))
|
|
207
|
+
status = parseInt(line.slice(16).trim(), 10) || 0;
|
|
208
|
+
else if (line.startsWith("__CURL_ETAG__:"))
|
|
209
|
+
newEtag = line.slice(14).trim() || null;
|
|
210
|
+
else
|
|
211
|
+
body.push(line);
|
|
212
|
+
}
|
|
213
|
+
return { status, etag: newEtag, body: body.join("\n") };
|
|
214
|
+
}
|
|
215
|
+
// Stable probe (releases/latest). On 304 serves the caller-supplied cached
|
|
216
|
+
// version with the same etag; If-None-Match is only sent when there IS a
|
|
217
|
+
// cached value to serve.
|
|
218
|
+
async function latestFromGithubApi(etag = null, cached = null) {
|
|
135
219
|
try {
|
|
136
|
-
const
|
|
137
|
-
if (
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
let etag = null;
|
|
143
|
-
const body = [];
|
|
144
|
-
for (const line of (res.stdout || "").split("\n")) {
|
|
145
|
-
if (line.startsWith("__CURL_STATUS__:"))
|
|
146
|
-
status = parseInt(line.slice(16).trim(), 10) || 0;
|
|
147
|
-
else if (line.startsWith("__CURL_ETAG__:"))
|
|
148
|
-
etag = line.slice(14).trim() || null;
|
|
149
|
-
else
|
|
150
|
-
body.push(line);
|
|
151
|
-
}
|
|
152
|
-
if (status === 304)
|
|
153
|
-
return apiState.latest;
|
|
154
|
-
if (status !== 200)
|
|
155
|
-
return null;
|
|
156
|
-
const tag = JSON.parse(body.join("\n")).tag_name;
|
|
220
|
+
const r = await curlConditional(RELEASES_LATEST_API, cached ? etag : null);
|
|
221
|
+
if (r.status === 304)
|
|
222
|
+
return { version: cached, etag };
|
|
223
|
+
if (r.status !== 200)
|
|
224
|
+
return { version: null, etag: null };
|
|
225
|
+
const tag = JSON.parse(r.body).tag_name;
|
|
157
226
|
const v = typeof tag === "string" ? parseSemverish(tag.replace(/^v/, "").trim()) : null;
|
|
158
|
-
|
|
159
|
-
apiState.etag = etag;
|
|
160
|
-
apiState.latest = v;
|
|
161
|
-
}
|
|
162
|
-
return v;
|
|
227
|
+
return v ? { version: v, etag: r.etag } : { version: null, etag: null };
|
|
163
228
|
}
|
|
164
229
|
catch {
|
|
165
|
-
return null;
|
|
230
|
+
return { version: null, etag: null };
|
|
166
231
|
}
|
|
167
232
|
}
|
|
168
233
|
async function latestFromNpm() {
|
|
@@ -177,18 +242,24 @@ async function latestFromNpm() {
|
|
|
177
242
|
}
|
|
178
243
|
// Staging channel: newest release OVERALL (prereleases included) from the
|
|
179
244
|
// releases LIST, since releases/latest excludes prereleases. Drafts are skipped;
|
|
180
|
-
// "newest" is by the rc-aware verKey.
|
|
181
|
-
|
|
245
|
+
// "newest" is by the rc-aware verKey. On 304 serves the caller-supplied cached
|
|
246
|
+
// {version, tag} with the same etag. Returns {version, tag, etag} or null.
|
|
247
|
+
async function latestFromGithubListStaging(etag = null, cached = null) {
|
|
182
248
|
try {
|
|
183
|
-
const
|
|
184
|
-
const
|
|
249
|
+
const haveCached = !!(cached && cached.version && cached.tag);
|
|
250
|
+
const r = await curlConditional(RELEASES_LIST_API, haveCached ? etag : null);
|
|
251
|
+
if (r.status === 304 && haveCached)
|
|
252
|
+
return { version: cached.version, tag: cached.tag, etag };
|
|
253
|
+
if (r.status !== 200)
|
|
254
|
+
return null;
|
|
255
|
+
const rels = JSON.parse(r.body || "[]");
|
|
185
256
|
if (!Array.isArray(rels))
|
|
186
257
|
return null;
|
|
187
258
|
let best = null;
|
|
188
|
-
for (const
|
|
189
|
-
if (!
|
|
259
|
+
for (const rel of rels) {
|
|
260
|
+
if (!rel || typeof rel !== "object" || rel.draft)
|
|
190
261
|
continue;
|
|
191
|
-
const tag =
|
|
262
|
+
const tag = rel.tag_name;
|
|
192
263
|
if (typeof tag !== "string")
|
|
193
264
|
continue;
|
|
194
265
|
const v = parseSemverish(tag.replace(/^v/, "").trim());
|
|
@@ -198,7 +269,7 @@ async function latestFromGithubListStaging() {
|
|
|
198
269
|
if (best == null || cmpKey(key, best.key) > 0)
|
|
199
270
|
best = { version: v, tag, key };
|
|
200
271
|
}
|
|
201
|
-
return best ? { version: best.version, tag: best.tag } : null;
|
|
272
|
+
return best ? { version: best.version, tag: best.tag, etag: r.etag } : null;
|
|
202
273
|
}
|
|
203
274
|
catch {
|
|
204
275
|
return null;
|
|
@@ -221,28 +292,66 @@ async function resolveLatest() {
|
|
|
221
292
|
const now = Date.now();
|
|
222
293
|
if (cache && cache.channel === channel && now - cache.at < TTL_MS)
|
|
223
294
|
return { version: cache.latest, tag: cache.tag, channel };
|
|
295
|
+
// Level 2: shared cross-process cache file (see SharedCache above).
|
|
296
|
+
const shared = readSharedCache(channel);
|
|
297
|
+
if (shared && now / 1000 - shared.at < SHARED_TTL_S) {
|
|
298
|
+
cache = { at: now, latest: shared.version, tag: shared.tag, channel };
|
|
299
|
+
return { version: shared.version, tag: shared.tag, channel };
|
|
300
|
+
}
|
|
301
|
+
const { acquired, lock } = tryProbeLock();
|
|
302
|
+
if (!acquired) {
|
|
303
|
+
// Another process is probing right now; serve the stale shared value (or
|
|
304
|
+
// null) instead of doubling the request. The short in-process TTL means we
|
|
305
|
+
// pick up its fresh result within a minute.
|
|
306
|
+
const v = shared?.version ?? null;
|
|
307
|
+
const t = shared?.tag ?? null;
|
|
308
|
+
cache = { at: now, latest: v, tag: t, channel };
|
|
309
|
+
return { version: v, tag: t, channel };
|
|
310
|
+
}
|
|
224
311
|
let version = null;
|
|
225
312
|
let tag = null;
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
if (
|
|
229
|
-
|
|
230
|
-
|
|
313
|
+
let etag = null;
|
|
314
|
+
try {
|
|
315
|
+
if (channel === "staging") {
|
|
316
|
+
const s = await latestFromGithubListStaging(shared?.etag ?? null, shared ? { version: shared.version, tag: shared.tag } : null);
|
|
317
|
+
if (s) {
|
|
318
|
+
version = s.version;
|
|
319
|
+
tag = s.tag;
|
|
320
|
+
etag = s.etag;
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
// Degrade to the stable probes so a staging box tracks at least stable
|
|
324
|
+
// rather than going blind when the list endpoint fails. etag stays
|
|
325
|
+
// null: a releases/latest etag must never be replayed against the
|
|
326
|
+
// LIST endpoint on the next staging probe.
|
|
327
|
+
version =
|
|
328
|
+
(await latestFromGithubApi()).version ?? (await latestFromGithubRedirect());
|
|
329
|
+
tag = version ? `v${version}` : null;
|
|
330
|
+
}
|
|
231
331
|
}
|
|
232
332
|
else {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
333
|
+
const r = await latestFromGithubApi(shared?.etag ?? null, shared?.version ?? null);
|
|
334
|
+
version = r.version;
|
|
335
|
+
etag = r.etag;
|
|
336
|
+
if (version == null) {
|
|
337
|
+
version = await latestFromGithubRedirect();
|
|
338
|
+
etag = null;
|
|
339
|
+
}
|
|
340
|
+
if (version == null)
|
|
341
|
+
version = await latestFromNpm();
|
|
236
342
|
tag = version ? `v${version}` : null;
|
|
237
343
|
}
|
|
344
|
+
writeSharedCache({ at: now / 1000, channel, version, tag, etag });
|
|
238
345
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
346
|
+
finally {
|
|
347
|
+
if (lock) {
|
|
348
|
+
try {
|
|
349
|
+
fs.unlinkSync(lock);
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
/* already gone */
|
|
353
|
+
}
|
|
354
|
+
}
|
|
246
355
|
}
|
|
247
356
|
cache = { at: now, latest: version, tag, channel };
|
|
248
357
|
return { version, tag, channel };
|
package/mcp/dist/version.json
CHANGED
package/mcp/manifest.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"dxt_version": "0.1",
|
|
3
3
|
"name": "social-autoposter",
|
|
4
4
|
"display_name": "S4L",
|
|
5
|
-
"version": "1.7.4
|
|
5
|
+
"version": "1.7.4",
|
|
6
6
|
"description": "Draft, review, approve, and autopilot X/Twitter posts.",
|
|
7
7
|
"long_description": "## **⚠️ The disclaimer above is generic Claude boilerplate.** Anthropic shows the same warning on every plugin regardless of what it does; any plugin has the same level of access as any app you download from the internet.\n\nS4L is an open source product developed by Mediar.ai Incorporated, a VC-backed San Francisco-based startup.\n\nTo get started:\n\n1\\. Copy this prompt: **Set me up on S4L plugin end to end**\n\n2\\. Quit with CMD+Q, reopen Claude, paste into a new chat.\n\nWhat happens next:\n\n* About every 5 minutes S4L scans X for posts that match your topics and drafts replies in your voice.\n* Drafts show up as review cards, usually the first within a few minutes. Nothing is posted automatically; you approve each one.\n* Posting autopilot stays off until you explicitly turn it on.",
|
|
8
8
|
"author": {
|
package/mcp/menubar/s4l_card.py
CHANGED
|
@@ -46,10 +46,11 @@ menu bar registers at boot via `set_feedback_handler` (it ships
|
|
|
46
46
|
decision='feedback' review events).
|
|
47
47
|
|
|
48
48
|
Rejecting is a two-step flow: the Reject button swaps the card body for a
|
|
49
|
-
reason picker (three one-tap categories
|
|
50
|
-
note). The category feeds the feedback-digest loop that distills
|
|
51
|
-
rejections into each project's learned_preferences config block;
|
|
52
|
-
|
|
49
|
+
reason picker (three one-tap categories, all optional, plus an optional
|
|
50
|
+
free-text note). The category feeds the feedback-digest loop that distills
|
|
51
|
+
human rejections into each project's learned_preferences config block; the
|
|
52
|
+
picker's red Reject button commits with no category (the note, if typed, is
|
|
53
|
+
still sent). Link clicks (author profile,
|
|
53
54
|
thread ↗) and per-card dwell time ride along on the decision so the digest can
|
|
54
55
|
infer intent (e.g. profile-checked-then-rejected = author-quality signal) even
|
|
55
56
|
when the reason is skipped.
|
|
@@ -185,13 +186,15 @@ M = 16
|
|
|
185
186
|
NS_BEZEL_BORDER = 2 # NSBezelBorder
|
|
186
187
|
|
|
187
188
|
# Reject-reason categories, in display order. Tags are 1-based button tags on
|
|
188
|
-
# the reason picker; values must
|
|
189
|
-
# constraint server-side (wrong_author | off_topic | bad_draft | other).
|
|
189
|
+
# the reason picker; values must be a subset of the review_events.reject_category
|
|
190
|
+
# CHECK constraint server-side (wrong_author | off_topic | bad_draft | other).
|
|
191
|
+
# "other" is still valid server-side but no longer offered as a button: picking
|
|
192
|
+
# a category is optional (the red Reject button commits without one) and the
|
|
193
|
+
# free-text note covers everything a generic Other chip used to.
|
|
190
194
|
REJECT_REASONS = (
|
|
191
195
|
("wrong_author", "Wrong author / audience"),
|
|
192
196
|
("off_topic", "Off-topic thread"),
|
|
193
|
-
("bad_draft", "
|
|
194
|
-
("other", "Other"),
|
|
197
|
+
("bad_draft", "Feels like AI writing"),
|
|
195
198
|
)
|
|
196
199
|
|
|
197
200
|
# Client-side cap on tracked interactions per card (server clips at 50 too).
|
|
@@ -431,7 +434,21 @@ def _details_lines(d):
|
|
|
431
434
|
d = d or {}
|
|
432
435
|
lines = []
|
|
433
436
|
style = (d.get("engagement_style") or "").strip()
|
|
434
|
-
|
|
437
|
+
# Two-draft cards (2026-07-11): name each slot's style so the reviewer
|
|
438
|
+
# can tell WHICH style produced the draft they are picking (Draft B is
|
|
439
|
+
# the exploration slot; its source arm renders separately below via the
|
|
440
|
+
# generic experiments lines). Single-draft cards keep the old one-liner.
|
|
441
|
+
_dual = d.get("drafts")
|
|
442
|
+
if isinstance(_dual, list) and len(_dual) == 2:
|
|
443
|
+
_slot_labels = {"a": "Draft A", "b": "Draft B"}
|
|
444
|
+
for _draft in _dual:
|
|
445
|
+
_s = (_draft.get("style") or "").strip()
|
|
446
|
+
if _s:
|
|
447
|
+
_label = _slot_labels.get(
|
|
448
|
+
(_draft.get("variant") or "").strip().lower(), "Draft"
|
|
449
|
+
)
|
|
450
|
+
lines.append(f"{_label} style: {_s}")
|
|
451
|
+
elif style:
|
|
435
452
|
lines.append(f"Style: {style}")
|
|
436
453
|
desc = (d.get("style_description") or "").strip()
|
|
437
454
|
if desc:
|
|
@@ -741,6 +758,12 @@ class _ReviewController(NSObject):
|
|
|
741
758
|
# timestamp of any hover still in progress (flushed on decision).
|
|
742
759
|
self._draft_hover_ms = {0: 0, 1: 0}
|
|
743
760
|
self._draft_hover_open = {}
|
|
761
|
+
# Slots the caret has actually been in this card (2026-07-10 follow-up):
|
|
762
|
+
# lets the decision distinguish "clicked into B, then came BACK to A"
|
|
763
|
+
# (an explicit head-to-head choice of A, per user) from "never touched
|
|
764
|
+
# B at all". Only the UNCHOSEN slot's membership matters at decision
|
|
765
|
+
# time; the selected slot is trivially visited.
|
|
766
|
+
self._draft_visited = set()
|
|
744
767
|
# Attention anchors for the unattended-review watchdog: the stack counts
|
|
745
768
|
# as "touched" on present, on any tracked interaction, and on any
|
|
746
769
|
# decision. No touch past the watchdog threshold = the user is not
|
|
@@ -1070,6 +1093,7 @@ class _ReviewController(NSObject):
|
|
|
1070
1093
|
self._selected_draft = None
|
|
1071
1094
|
self._draft_hover_ms = {0: 0, 1: 0}
|
|
1072
1095
|
self._draft_hover_open = {}
|
|
1096
|
+
self._draft_visited = set()
|
|
1073
1097
|
self._reason_field = None
|
|
1074
1098
|
content = NSView.alloc().initWithFrame_(NSMakeRect(0, 0, W, H))
|
|
1075
1099
|
|
|
@@ -1688,11 +1712,18 @@ class _ReviewController(NSObject):
|
|
|
1688
1712
|
except Exception:
|
|
1689
1713
|
return
|
|
1690
1714
|
for slot, cand_tv in (self._draft_textviews or {}).items():
|
|
1691
|
-
if cand_tv is tv
|
|
1715
|
+
if cand_tv is not tv:
|
|
1716
|
+
continue
|
|
1717
|
+
# Visited even when it's already the selected slot: membership of
|
|
1718
|
+
# the eventually-UNCHOSEN slot is what _record reads, and that
|
|
1719
|
+
# slot only ever gets the caret via a deliberate user click (the
|
|
1720
|
+
# auto-focus seat in _render targets the selected slot only).
|
|
1721
|
+
self._draft_visited.add(slot)
|
|
1722
|
+
if slot != self._selected_draft:
|
|
1692
1723
|
self._selected_draft = slot
|
|
1693
1724
|
self._textview = cand_tv
|
|
1694
1725
|
self._update_draft_borders()
|
|
1695
|
-
|
|
1726
|
+
break
|
|
1696
1727
|
|
|
1697
1728
|
@objc.python_method
|
|
1698
1729
|
def _update_draft_borders(self):
|
|
@@ -1779,6 +1810,10 @@ class _ReviewController(NSObject):
|
|
|
1779
1810
|
"unchosen_style": other.get("style") or None,
|
|
1780
1811
|
"hover_a_ms": int(self._draft_hover_ms.get(0, 0)),
|
|
1781
1812
|
"hover_b_ms": int(self._draft_hover_ms.get(1, 0)),
|
|
1813
|
+
# True = the caret was in the unchosen box at some point, i.e.
|
|
1814
|
+
# they tried the other draft and came back: an explicit choice
|
|
1815
|
+
# even when the winner is the preselected default.
|
|
1816
|
+
"visited_other": bool((1 - sel_idx) in self._draft_visited),
|
|
1782
1817
|
}
|
|
1783
1818
|
else:
|
|
1784
1819
|
orig = (d.get("reply_text") or "").strip()
|
|
@@ -1958,10 +1993,11 @@ class _ReviewController(NSObject):
|
|
|
1958
1993
|
content = NSView.alloc().initWithFrame_(NSMakeRect(0, 0, W, H))
|
|
1959
1994
|
|
|
1960
1995
|
# Buttons at the TOP, above the title, mirroring the card's action
|
|
1961
|
-
# row so the cursor doesn't have to travel between steps: "Reject
|
|
1962
|
-
#
|
|
1963
|
-
#
|
|
1964
|
-
# left where Approve
|
|
1996
|
+
# row so the cursor doesn't have to travel between steps: "Reject"
|
|
1997
|
+
# sits exactly where the card's Reject was (one-click double-tap
|
|
1998
|
+
# keeps the old zero-friction reject) and commits with no category,
|
|
1999
|
+
# since picking one is optional; Back sits at the left where Approve
|
|
2000
|
+
# was.
|
|
1965
2001
|
back = NSButton.alloc().initWithFrame_(NSMakeRect(M, H - 42, 90, 30))
|
|
1966
2002
|
back.setTitle_("Back")
|
|
1967
2003
|
back.setBezelStyle_(NSBezelStyleRounded)
|
|
@@ -1969,8 +2005,8 @@ class _ReviewController(NSObject):
|
|
|
1969
2005
|
back.setAction_("rejectBack:")
|
|
1970
2006
|
content.addSubview_(back)
|
|
1971
2007
|
|
|
1972
|
-
skip = NSButton.alloc().initWithFrame_(NSMakeRect(W - M -
|
|
1973
|
-
skip.setTitle_("Reject
|
|
2008
|
+
skip = NSButton.alloc().initWithFrame_(NSMakeRect(W - M - 66, H - 42, 66, 30))
|
|
2009
|
+
skip.setTitle_("Reject")
|
|
1974
2010
|
skip.setBezelStyle_(NSBezelStyleRounded)
|
|
1975
2011
|
try:
|
|
1976
2012
|
skip.setHasDestructiveAction_(True) # red title on macOS 12+
|
|
@@ -1981,7 +2017,7 @@ class _ReviewController(NSObject):
|
|
|
1981
2017
|
content.addSubview_(skip)
|
|
1982
2018
|
|
|
1983
2019
|
content.addSubview_(
|
|
1984
|
-
_label(NSMakeRect(M, H - 72, W - 2 * M, 20), "Why reject this draft?", size=13, bold=True)
|
|
2020
|
+
_label(NSMakeRect(M, H - 72, W - 2 * M, 20), "Why reject this draft? (optional)", size=13, bold=True)
|
|
1985
2021
|
)
|
|
1986
2022
|
y = H - 106
|
|
1987
2023
|
for i, (_, title) in enumerate(REJECT_REASONS):
|
|
@@ -2029,7 +2065,7 @@ class _ReviewController(NSObject):
|
|
|
2029
2065
|
note.setEditable_(True)
|
|
2030
2066
|
note.setFont_(NSFont.systemFontOfSize_(12))
|
|
2031
2067
|
try:
|
|
2032
|
-
note.setPlaceholderString_("Optional note (sent with
|
|
2068
|
+
note.setPlaceholderString_("Optional note (sent with or without a reason)")
|
|
2033
2069
|
note.cell().setWraps_(True)
|
|
2034
2070
|
except Exception:
|
|
2035
2071
|
pass
|