@m13v/s4l 1.7.6-rc.8 → 1.7.6
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 +1 -1
- package/bin/cli.js +33 -9
- package/mcp/dist/index.js +290 -177
- package/mcp/dist/panel.html +31 -17
- package/mcp/dist/setup.js +27 -7
- package/mcp/dist/telemetry.js +15 -6
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +10 -2
- package/mcp/menubar/s4l_browser_foreground.py +7 -1
- package/mcp/menubar/s4l_card.py +63 -4
- package/mcp/menubar/s4l_card_canvas.py +62 -11
- package/mcp/menubar/s4l_menubar.py +380 -95
- package/mcp/menubar/s4l_state.py +119 -41
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/account_resolver.py +58 -14
- package/scripts/amplitude_24h_signups.py +7 -4
- package/scripts/amplitude_signups.py +7 -4
- package/scripts/autopilot_stall_watch.py +8 -1
- package/scripts/browser_lifecycle.py +90 -0
- package/scripts/cdp_ready_check.py +81 -50
- package/scripts/claude_job.py +70 -10
- package/scripts/dm_conversation.py +15 -12
- package/scripts/draft_prompt_core.py +132 -64
- package/scripts/engage_github.py +11 -5
- package/scripts/engage_reddit.py +12 -5
- package/scripts/engagement_signal_feedback.py +1 -4
- package/scripts/engagement_styles.py +144 -105
- package/scripts/feedback_digest.py +54 -11
- package/scripts/find_threads.py +9 -7
- package/scripts/github_tools.py +23 -10
- package/scripts/learned_preferences.py +112 -78
- package/scripts/li_discovery.py +4 -1
- package/scripts/link_tail.py +1 -1
- package/scripts/live_status.py +151 -0
- package/scripts/merge_review_queue.py +15 -2
- package/scripts/patches/browser-harness-focus-and-observability.patch +130 -0
- package/scripts/pick_project.py +7 -4
- package/scripts/pick_thread_target.py +64 -13
- package/scripts/pick_twitter_thread_target.py +7 -4
- package/scripts/platform_strike_events.py +139 -7
- package/scripts/post_github.py +376 -35
- package/scripts/post_reddit.py +202 -192
- package/scripts/preflight.sh +23 -3
- package/scripts/preview_reddit_card.py +38 -0
- package/scripts/project_stats.py +7 -4
- package/scripts/reddit_ban_check.py +324 -0
- package/scripts/reddit_browser.py +56 -19
- package/scripts/reddit_chat_sync.py +10 -11
- package/scripts/reddit_tools.py +20 -4
- package/scripts/release-mcpb.sh +47 -7
- package/scripts/run_moltbook_cycle.py +9 -5
- package/scripts/s4l_ctl.sh +2 -2
- package/scripts/s4l_posting_mode.py +67 -6
- package/scripts/scan_dm_candidates.py +15 -9
- package/scripts/scan_github_replies.py +9 -7
- package/scripts/scan_moltbook_replies.py +7 -6
- package/scripts/scan_reddit_replies.py +11 -7
- package/scripts/scan_twitter_mentions_browser.py +7 -6
- package/scripts/scan_twitter_thread_followups.py +7 -6
- package/scripts/schedule_state.py +8 -4
- package/scripts/score_twitter_candidates.py +8 -3
- package/scripts/setup_twitter_auth.py +20 -96
- package/scripts/snapshot.py +19 -0
- package/scripts/stats.py +12 -10
- package/scripts/strike_alert.py +165 -12
- package/scripts/sub_moderation_history.py +129 -0
- package/scripts/subreddit_bans_client.py +157 -0
- package/scripts/test_cross_language_parity.py +185 -0
- package/scripts/test_no_silent_fallbacks.py +84 -0
- package/scripts/test_percard_posting.py +3 -3
- package/scripts/test_stall_watch_batch_progression.py +99 -0
- package/scripts/top_performers.py +24 -0
- package/scripts/twitter_browser.py +22 -52
- package/scripts/twitter_gen_links.py +3 -3
- package/scripts/twitter_post_plan.py +4 -4
- package/scripts/virality.py +151 -0
- package/setup/SKILL.md +1 -1
- package/skill/engage-dm-replies.sh +11 -0
- package/skill/lib/reddit-backend.sh +1 -0
- package/skill/run-draft-and-publish.sh +16 -0
- package/skill/run-reddit-search-launchd.sh +1 -0
- package/skill/run-reddit-search.sh +10 -1
- package/skill/run-reddit-threads.sh +12 -0
- package/skill/run-twitter-cycle.sh +104 -88
package/mcp/dist/setup.js
CHANGED
|
@@ -192,11 +192,19 @@ export function projectExists(name) {
|
|
|
192
192
|
return false;
|
|
193
193
|
}
|
|
194
194
|
}
|
|
195
|
-
// Strip stray JSON-array syntax that leaks into a single
|
|
196
|
-
// stringified array
|
|
197
|
-
// surrounding quotes on each element). Without this, topics like '["AI
|
|
198
|
-
// generation"' and '"ex-Google"]' get seeded verbatim and poison every
|
|
199
|
-
// query with literal [, ] and " characters (Karol, 2026-06-30).
|
|
195
|
+
// Strip stray JSON-array syntax that leaks into a single element ONLY when a
|
|
196
|
+
// stringified array had to be split on commas (leading "[", trailing "]", and
|
|
197
|
+
// the surrounding quotes on each element). Without this, topics like '["AI
|
|
198
|
+
// video generation"' and '"ex-Google"]' get seeded verbatim and poison every
|
|
199
|
+
// search query with literal [, ] and " characters (Karol, 2026-06-30).
|
|
200
|
+
//
|
|
201
|
+
// IMPORTANT: apply this ONLY on the naive-split fallback path. On the clean
|
|
202
|
+
// paths (a real array, or a valid stringified array that JSON.parse handled)
|
|
203
|
+
// each element is already clean, so stripping surrounding quotes there would
|
|
204
|
+
// destroy legitimate phrase-match quotes in a search query — e.g.
|
|
205
|
+
// '"agentic coding" min_faves:10' would lose its LEADING " and seed as
|
|
206
|
+
// 'agentic coding" min_faves:10', weakening the phrase match (reported
|
|
207
|
+
// 2026-07-17). Clean paths therefore only get trimmed.
|
|
200
208
|
function stripTopicSyntax(x) {
|
|
201
209
|
let s = String(x).trim();
|
|
202
210
|
s = s.replace(/^\[+/, "").replace(/\]+$/, "").trim();
|
|
@@ -213,6 +221,9 @@ export function normalizeStringList(t) {
|
|
|
213
221
|
if (t == null)
|
|
214
222
|
return undefined;
|
|
215
223
|
let raw;
|
|
224
|
+
// Elements only carry stray JSON-array syntax when we reach the naive
|
|
225
|
+
// comma/newline split below; on the clean paths they're already delimited.
|
|
226
|
+
let fromNaiveSplit = false;
|
|
216
227
|
if (Array.isArray(t)) {
|
|
217
228
|
raw = t;
|
|
218
229
|
}
|
|
@@ -227,9 +238,18 @@ export function normalizeStringList(t) {
|
|
|
227
238
|
parsed = null;
|
|
228
239
|
}
|
|
229
240
|
}
|
|
230
|
-
|
|
241
|
+
if (Array.isArray(parsed)) {
|
|
242
|
+
raw = parsed;
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
raw = s.split(/[,\n]/);
|
|
246
|
+
fromNaiveSplit = true;
|
|
247
|
+
}
|
|
231
248
|
}
|
|
232
|
-
|
|
249
|
+
const clean = fromNaiveSplit
|
|
250
|
+
? stripTopicSyntax
|
|
251
|
+
: (x) => String(x).trim();
|
|
252
|
+
return raw.map((x) => clean(String(x))).filter(Boolean);
|
|
233
253
|
}
|
|
234
254
|
function normalizeTopics(t) {
|
|
235
255
|
return normalizeStringList(t);
|
package/mcp/dist/telemetry.js
CHANGED
|
@@ -139,12 +139,21 @@ export function checkVersionChange() {
|
|
|
139
139
|
}
|
|
140
140
|
const isFirstBoot = from === null;
|
|
141
141
|
const changed = !isFirstBoot && from !== to;
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
142
|
+
// Write ONLY when the version actually changed (or first boot / unreadable
|
|
143
|
+
// file). The old rewrite-every-boot kept the file's timestamp meaning "when
|
|
144
|
+
// the server last booted", which reads like an install time and misled a
|
|
145
|
+
// 2026-07-18 investigation into concluding an update hadn't applied. With
|
|
146
|
+
// the guard, running_since is exactly what it says: when THIS version first
|
|
147
|
+
// booted here. Same-version boots leave the file untouched, so detection
|
|
148
|
+
// semantics are unchanged (the file already holds the current version).
|
|
149
|
+
if (changed || isFirstBoot) {
|
|
150
|
+
try {
|
|
151
|
+
fs.mkdirSync(snapshotStateDir(), { recursive: true });
|
|
152
|
+
fs.writeFileSync(lastVersionPath(), JSON.stringify({ version: to, running_since: new Date().toISOString() }), "utf-8");
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
/* best-effort; a failed write just means the next boot re-detects from "from: null" */
|
|
156
|
+
}
|
|
148
157
|
}
|
|
149
158
|
if (changed) {
|
|
150
159
|
console.error(`[social-autoposter-mcp] self-update detected: ${from} -> ${to}`);
|
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.6
|
|
5
|
+
"version": "1.7.6",
|
|
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": {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"description": "Configure or edit a project"
|
|
34
34
|
},
|
|
35
35
|
{
|
|
36
|
-
"name": "
|
|
36
|
+
"name": "approve_drafts",
|
|
37
37
|
"description": "Post chosen drafts"
|
|
38
38
|
},
|
|
39
39
|
{
|
|
@@ -48,6 +48,14 @@
|
|
|
48
48
|
"name": "restart_menubar",
|
|
49
49
|
"description": "Restart the S4L menu bar app"
|
|
50
50
|
},
|
|
51
|
+
{
|
|
52
|
+
"name": "run_drafting",
|
|
53
|
+
"description": "Run the drafting pipeline now"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"name": "drafting_status",
|
|
57
|
+
"description": "Live drafting-pipeline status + next-run countdown"
|
|
58
|
+
},
|
|
51
59
|
{
|
|
52
60
|
"name": "posting_volume",
|
|
53
61
|
"description": "Read or set posting volume (Aggressive / Steady / Chill)"
|
|
@@ -46,7 +46,13 @@ import s4l_log_relay
|
|
|
46
46
|
|
|
47
47
|
# A managed harness Chrome is one launched on a profile under this marker
|
|
48
48
|
# (browser-harness = twitter 9555, browser-harness-linkedin = 9556, ...).
|
|
49
|
-
|
|
49
|
+
# Any managed harness profile under browser-profiles/ counts — matching the
|
|
50
|
+
# literal "browser-harness" prefix silently EXCLUDED the reddit harness
|
|
51
|
+
# (profile "reddit-harness"), so reddit-Chrome activations went unlogged and
|
|
52
|
+
# unattributable for days (2026-07-17). The remote-debugging-port requirement
|
|
53
|
+
# in the check below keeps the user's own Chrome / MCP-agent profiles (which
|
|
54
|
+
# use the debugging PIPE, not a port) out of scope.
|
|
55
|
+
_PROFILE_MARKER = os.path.join(".claude", "browser-profiles", "")
|
|
50
56
|
|
|
51
57
|
# Within this window, repeats of the same (cause, pid) are counted, not emitted.
|
|
52
58
|
# A screencast-reconnect storm raises Chrome every few seconds; one line per
|
package/mcp/menubar/s4l_card.py
CHANGED
|
@@ -331,7 +331,7 @@ def _truncate(s, n=320):
|
|
|
331
331
|
return s if len(s) <= n else s[: n - 1] + "…"
|
|
332
332
|
|
|
333
333
|
|
|
334
|
-
def _set_thread_text(thread_tv, text, link_url, *, font_size=12):
|
|
334
|
+
def _set_thread_text(thread_tv, text, link_url, *, font_size=12, title=None):
|
|
335
335
|
"""Set the thread-quote text on `thread_tv`: the FULL text, never
|
|
336
336
|
trimmed (2026-07-16 user direction -- the box scrolls instead when it
|
|
337
337
|
doesn't fit; see its NSScrollView wrapper in _render()). A leading
|
|
@@ -340,8 +340,15 @@ def _set_thread_text(thread_tv, text, link_url, *, font_size=12):
|
|
|
340
340
|
of how long the thread is or where the box happens to be scrolled to
|
|
341
341
|
(the old trailing-link placement could end up scrolled out of view, or
|
|
342
342
|
-- before scrolling existed at all -- get silently truncated off the
|
|
343
|
-
end; see the superseded _fit_thread_body this replaced).
|
|
343
|
+
end; see the superseded _fit_thread_body this replaced).
|
|
344
|
+
|
|
345
|
+
`title` (2026-07-17 user direction, Reddit): when given, it's drawn BOLD
|
|
346
|
+
on its own line ahead of `text`, so a Reddit card shows the post title in
|
|
347
|
+
bold with the selftext body beneath it in the same quote box. Twitter and
|
|
348
|
+
title-only Reddit threads pass title=None and render `text` alone,
|
|
349
|
+
exactly as before."""
|
|
344
350
|
text = (text or "").strip()
|
|
351
|
+
title = (title or "").strip()
|
|
345
352
|
body = NSMutableAttributedString.alloc().initWithString_attributes_("", {})
|
|
346
353
|
if link_url:
|
|
347
354
|
body.appendAttributedString_(
|
|
@@ -356,6 +363,19 @@ def _set_thread_text(thread_tv, text, link_url, *, font_size=12):
|
|
|
356
363
|
},
|
|
357
364
|
)
|
|
358
365
|
)
|
|
366
|
+
if title:
|
|
367
|
+
# Bold title on its own line; a trailing newline separates it from the
|
|
368
|
+
# body only when there IS a body (a title with no selftext shouldn't
|
|
369
|
+
# leave a dangling blank line).
|
|
370
|
+
body.appendAttributedString_(
|
|
371
|
+
NSAttributedString.alloc().initWithString_attributes_(
|
|
372
|
+
title + ("\n" if text else ""),
|
|
373
|
+
{
|
|
374
|
+
NSFontAttributeName: NSFont.boldSystemFontOfSize_(font_size),
|
|
375
|
+
NSForegroundColorAttributeName: NSColor.labelColor(),
|
|
376
|
+
},
|
|
377
|
+
)
|
|
378
|
+
)
|
|
359
379
|
body.appendAttributedString_(
|
|
360
380
|
NSAttributedString.alloc().initWithString_attributes_(
|
|
361
381
|
text,
|
|
@@ -425,8 +445,26 @@ def _engagement_line(stats):
|
|
|
425
445
|
return " · ".join(parts)
|
|
426
446
|
|
|
427
447
|
|
|
448
|
+
def _compact_arm(variant):
|
|
449
|
+
"""Shrink a draft_prompt A/B variant to a heading-sized token so the arm
|
|
450
|
+
fits inline next to project/lane/viral: 'treatment_v4' -> 't v4',
|
|
451
|
+
'control_v4' -> 'c v4'. Anything that isn't a treatment/control_vN shape
|
|
452
|
+
(future arm names, retired bare 'treatment'/'control') falls back to the
|
|
453
|
+
raw variant, so no arm goes unlabelled. The full one-sentence meaning
|
|
454
|
+
still lives in the details popover via active_experiments.describe()."""
|
|
455
|
+
v = (variant or "").strip()
|
|
456
|
+
if not v:
|
|
457
|
+
return ""
|
|
458
|
+
m = re.match(r"(treatment|control)(?:_v?(\w+))?$", v)
|
|
459
|
+
if not m:
|
|
460
|
+
return v
|
|
461
|
+
arm = "t" if m.group(1) == "treatment" else "c"
|
|
462
|
+
ver = m.group(2)
|
|
463
|
+
return f"{arm} v{ver}" if ver else arm
|
|
464
|
+
|
|
465
|
+
|
|
428
466
|
def _reply_heading_suffix(d):
|
|
429
|
-
"""Concise 'project/lane · viral N' readout appended straight onto the
|
|
467
|
+
"""Concise 'project/lane · viral N · t v4' readout appended straight onto the
|
|
430
468
|
'Reply (editable):' heading (2026-07-07), so the two facts a reviewer
|
|
431
469
|
checks first don't require opening the details eye. Pulled from the same
|
|
432
470
|
fields _details_lines used to render as 'Project:'/'Lane:'/'Virality
|
|
@@ -465,6 +503,15 @@ def _reply_heading_suffix(d):
|
|
|
465
503
|
bits.append(f"viral {float(v):g}")
|
|
466
504
|
except (TypeError, ValueError):
|
|
467
505
|
pass
|
|
506
|
+
# The drafting A/B arm (treatment vs control), compacted to 't v4'/'c v4'
|
|
507
|
+
# so the reviewer sees which prompt variant produced this draft without
|
|
508
|
+
# opening the details eye. Only draft_prompt rides the heading; other
|
|
509
|
+
# experiments stay solely in the popover. draft_prompt ALSO keeps its full
|
|
510
|
+
# one-sentence meaning in the popover (unlike 'lane', which is skipped
|
|
511
|
+
# there) because the compact token alone doesn't say what the arm does.
|
|
512
|
+
arm = _compact_arm((d.get("experiments") or {}).get("draft_prompt"))
|
|
513
|
+
if arm:
|
|
514
|
+
bits.append(arm)
|
|
468
515
|
return " · ".join(bits)
|
|
469
516
|
|
|
470
517
|
|
|
@@ -1571,7 +1618,19 @@ class _ReviewController(NSObject):
|
|
|
1571
1618
|
pass
|
|
1572
1619
|
if thread_url:
|
|
1573
1620
|
thread_tv.setDelegate_(self)
|
|
1574
|
-
|
|
1621
|
+
# Reddit (2026-07-17 user direction): the thread has a TITLE and a
|
|
1622
|
+
# BODY (selftext). Render the title bold with the body beneath it in
|
|
1623
|
+
# this same quote box. thread_text carries the title (see
|
|
1624
|
+
# merge_review_queue._reddit_plan_to_candidates); thread_selftext is
|
|
1625
|
+
# the body, empty for link posts / title-only threads (then this falls
|
|
1626
|
+
# through to the plain title-only render). Twitter has no selftext, so
|
|
1627
|
+
# it always takes the else branch and renders exactly as before.
|
|
1628
|
+
_thread_title = thread_en or d.get("thread_text")
|
|
1629
|
+
_thread_body = (d.get("thread_selftext") or "").strip() if _plat == "reddit" else ""
|
|
1630
|
+
if _thread_body:
|
|
1631
|
+
_set_thread_text(thread_tv, _thread_body, thread_url, title=_thread_title)
|
|
1632
|
+
else:
|
|
1633
|
+
_set_thread_text(thread_tv, _thread_title, thread_url)
|
|
1575
1634
|
thread_scroll.setDocumentView_(thread_tv)
|
|
1576
1635
|
content.addSubview_(thread_scroll)
|
|
1577
1636
|
# Reply heading — bold. A concise "project/lane · viral N" tag rides
|
|
@@ -69,6 +69,7 @@ from AppKit import (
|
|
|
69
69
|
NSWindowOcclusionStateVisible,
|
|
70
70
|
NSBackingStoreBuffered,
|
|
71
71
|
NSNormalWindowLevel,
|
|
72
|
+
NSFloatingWindowLevel,
|
|
72
73
|
NSApplicationActivationPolicyAccessory,
|
|
73
74
|
NSWindowStyleMaskTitled,
|
|
74
75
|
NSWindowStyleMaskClosable,
|
|
@@ -247,13 +248,57 @@ class _CanvasController(NSObject):
|
|
|
247
248
|
panel.orderFrontRegardless()
|
|
248
249
|
self._log_surface("presented")
|
|
249
250
|
if self._focus:
|
|
251
|
+
self.raise_to_front()
|
|
252
|
+
|
|
253
|
+
@objc.python_method
|
|
254
|
+
def raise_to_front(self):
|
|
255
|
+
"""Reliably bring the canvas above the current front app ONCE. The panel
|
|
256
|
+
is a NON-activating panel at NSNormalWindowLevel (so it behaves like a
|
|
257
|
+
normal window the reviewer works IN, per 2026-07-16), which means a plain
|
|
258
|
+
makeKeyAndOrderFront on our accessory app can leave it BEHIND the
|
|
259
|
+
frontmost app: window-level ordering, not activation, decides what covers
|
|
260
|
+
what, and a normal-level window can't jump above another normal-level
|
|
261
|
+
window that belongs to the active app. That is exactly the 2026-07-17
|
|
262
|
+
Nhat report -- the canvas 'presented' occluded ("didn't surface"). Fix:
|
|
263
|
+
briefly promote to NSFloatingWindowLevel so orderFrontRegardless is
|
|
264
|
+
guaranteed to place it in front regardless of activation, then demote
|
|
265
|
+
back to normal a beat later so other apps can cover it again (keeping the
|
|
266
|
+
"work IN it like a normal window" posture; always-on-top is the corner
|
|
267
|
+
card's job, not this one)."""
|
|
268
|
+
panel = self._panel
|
|
269
|
+
if panel is None:
|
|
270
|
+
return
|
|
271
|
+
try:
|
|
272
|
+
NSApp.setActivationPolicy_(NSApplicationActivationPolicyAccessory)
|
|
273
|
+
NSApp.activateIgnoringOtherApps_(True)
|
|
274
|
+
except Exception:
|
|
275
|
+
pass
|
|
276
|
+
try:
|
|
277
|
+
panel.setLevel_(NSFloatingWindowLevel)
|
|
278
|
+
except Exception:
|
|
279
|
+
pass
|
|
280
|
+
try:
|
|
281
|
+
panel.makeKeyAndOrderFront_(None)
|
|
282
|
+
panel.orderFrontRegardless()
|
|
283
|
+
except Exception:
|
|
284
|
+
pass
|
|
285
|
+
# Drop back to a normal window level once it's on screen. performSelector
|
|
286
|
+
# keeps it above other windows just long enough to land in front; if
|
|
287
|
+
# scheduling ever fails, demote inline so it can never stay stuck on top.
|
|
288
|
+
try:
|
|
289
|
+
self.performSelector_withObject_afterDelay_("demoteWindowLevel:", None, 0.6)
|
|
290
|
+
except Exception:
|
|
250
291
|
try:
|
|
251
|
-
|
|
252
|
-
NSApp.activateIgnoringOtherApps_(True)
|
|
292
|
+
panel.setLevel_(NSNormalWindowLevel)
|
|
253
293
|
except Exception:
|
|
254
294
|
pass
|
|
255
|
-
|
|
256
|
-
|
|
295
|
+
|
|
296
|
+
def demoteWindowLevel_(self, _):
|
|
297
|
+
try:
|
|
298
|
+
if self._panel is not None:
|
|
299
|
+
self._panel.setLevel_(NSNormalWindowLevel)
|
|
300
|
+
except Exception:
|
|
301
|
+
pass
|
|
257
302
|
|
|
258
303
|
@objc.python_method
|
|
259
304
|
def _render(self):
|
|
@@ -457,6 +502,15 @@ class _CanvasController(NSObject):
|
|
|
457
502
|
self._reflow_from(idx)
|
|
458
503
|
self._resize_doc()
|
|
459
504
|
self._refresh_header()
|
|
505
|
+
# Last card decided -> nothing left to review, so close the canvas
|
|
506
|
+
# instead of leaving an empty "0 pending drafts" window on screen.
|
|
507
|
+
# _finish() fires on_complete with every decision made this session,
|
|
508
|
+
# exactly like the corner card auto-finishing past its final draft
|
|
509
|
+
# (s4l_card._advance). Reached from a tile completing (approve/reject)
|
|
510
|
+
# and from prune_drafts draining the last candidate; either way an
|
|
511
|
+
# empty canvas has no reason to stay up.
|
|
512
|
+
if not self._order and self._panel is not None:
|
|
513
|
+
self._finish()
|
|
460
514
|
|
|
461
515
|
# Approve All / Discard All buttons live in the header row (see
|
|
462
516
|
# _render()), not a dedicated card -- these two actions just target the
|
|
@@ -746,13 +800,10 @@ def focus_active():
|
|
|
746
800
|
if c is None or c._panel is None:
|
|
747
801
|
return False
|
|
748
802
|
try:
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
try:
|
|
754
|
-
c._panel.makeKeyAndOrderFront_(None)
|
|
755
|
-
c._panel.orderFrontRegardless()
|
|
803
|
+
# Same reliable front-raise as the initial focused present: a brief
|
|
804
|
+
# window-level promotion so an already-open canvas comes above the front
|
|
805
|
+
# app even though it is a non-activating normal-level panel.
|
|
806
|
+
c.raise_to_front()
|
|
756
807
|
c._panel.makeMainWindow()
|
|
757
808
|
return True
|
|
758
809
|
except Exception:
|