Skip to content

Commit a9ddf8b

Browse files
committed
merge: dev8 → master — bulk-ack messaggi team (route ack-all + segna-tutti-letti) + normalizza escape \n nei messaggi/profilo (jht-notify-user)
2 parents d34d58e + 2895eac commit a9ddf8b

7 files changed

Lines changed: 180 additions & 9 deletions

File tree

agents/_tools/jht-notify-user

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,19 @@ def resolve_db_path() -> str:
3838
return os.path.join(jht_home, "jobs.db")
3939

4040

41+
def interpret_escapes(text: str) -> str:
42+
"""Converte le escape letterali \\n \\t \\r in caratteri veri.
43+
44+
Gli agenti (LLM) scrivono `\\n\\n` come separatore di paragrafi in una
45+
stringa singola, confidando che venga interpretato. `jht-telegram-send`
46+
fa gia' questa conversione (vedi commento li', screenshot 2026-05-16);
47+
senza la stessa conversione qui il body finiva in pending_user_messages
48+
— e quindi in dashboard — coi 4 caratteri letterali `\\`, `n`, `\\`, `n`.
49+
Interpretiamo solo le escape comuni, mai un decode generico fragile.
50+
"""
51+
return text.replace("\\n", "\n").replace("\\t", "\t").replace("\\r", "\r")
52+
53+
4154
def _is_off_hours() -> bool:
4255
"""True se la config ha working_hours e l'orario corrente e' fuori finestra.
4356
@@ -164,6 +177,10 @@ def main() -> int:
164177
print("jht-notify-user: body vuoto", file=sys.stderr)
165178
return 1
166179

180+
# Normalizza una sola volta: il body interpretato finisce sia in DB
181+
# (e quindi in dashboard web via cloud push) sia in Telegram, coerente.
182+
body = interpret_escapes(args.body)
183+
167184
db_path = resolve_db_path()
168185
if not os.path.exists(db_path):
169186
print(f"jht-notify-user: DB non trovato: {db_path}", file=sys.stderr)
@@ -173,7 +190,7 @@ def main() -> int:
173190
row_id = insert_pending(
174191
db_path,
175192
agent=args.agent,
176-
body=args.body,
193+
body=body,
177194
kind=args.kind,
178195
related_position_id=args.position_id,
179196
)
@@ -183,7 +200,7 @@ def main() -> int:
183200

184201
delivered_via = "web"
185202
off_hours = _is_off_hours()
186-
if not args.no_telegram and not off_hours and try_telegram(args.agent, args.body):
203+
if not args.no_telegram and not off_hours and try_telegram(args.agent, body):
187204
delivered_via = "telegram"
188205
if off_hours:
189206
print(

shared/skills/_db.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ def ensure_schema(conn: sqlite3.Connection):
119119
source TEXT,
120120
jd_text TEXT,
121121
requirements TEXT,
122+
jd_summary TEXT,
122123
found_by TEXT,
123124
found_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
124125
deadline TEXT,

shared/skills/db_update.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,21 @@ def _guard_role_family(conn, raw, _active=None):
8181
return _SENTINEL, v
8282

8383

84+
def interpret_escapes(text):
85+
"""Converte le escape LETTERALI \\n \\t \\r in caratteri veri.
86+
87+
Gli agenti (LLM) scrivono `\\n\\n` come separatori di paragrafo dentro una
88+
stringa singola passata come arg CLI. Senza questa conversione finirebbero
89+
LETTERALI in DB e quindi in dashboard (i `\\n` resterebbero visibili come
90+
testo). Coerente con jht-telegram-send / jht-notify-user. Applicato ai soli
91+
campi free-text leggibili dall'utente (notes, jd_summary), non ai campi
92+
scraped (jd_text/requirements).
93+
"""
94+
if text is None:
95+
return None
96+
return text.replace("\\n", "\n").replace("\\t", "\t").replace("\\r", "\r")
97+
98+
8499
def update_position(args):
85100
conn = get_db()
86101
ensure_schema(conn)
@@ -106,15 +121,15 @@ def update_position(args):
106121
changed.append(f"status={args.status}")
107122
if args.notes:
108123
updates.append("notes = ?")
109-
params.append(args.notes)
124+
params.append(interpret_escapes(args.notes))
110125
changed.append(f"notes={args.notes[:40]}...")
111126
if args.jd_text:
112127
updates.append("jd_text = ?")
113128
params.append(args.jd_text)
114129
changed.append("jd_text")
115130
if args.jd_summary:
116131
updates.append("jd_summary = ?")
117-
params.append(args.jd_summary)
132+
params.append(interpret_escapes(args.jd_summary))
118133
changed.append("jd_summary")
119134
if args.requirements:
120135
updates.append("requirements = ?")
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { NextResponse } from "next/server";
2+
import { requireAuth, isLocalRequest } from "@/lib/auth";
3+
import { isSupabaseConfigured } from "@/lib/workspace";
4+
import { getWorkspacePath } from "@/lib/workspace";
5+
import { createClient } from "@/lib/supabase/server";
6+
import { ackAllPendingMessagesLocal } from "@/lib/local-queries";
7+
8+
export const dynamic = "force-dynamic";
9+
10+
// Marca come letti TUTTI i messaggi web pendenti dell'utente in un colpo solo.
11+
// Stesso filtro della query che popola la card (delivered_via='web' e non
12+
// ancora ack): cosi' azzera esattamente cio' che la dashboard mostra. I DIGEST
13+
// del team si riaccumulano nel tempo e ackarli uno a uno non e' praticabile.
14+
// Idempotente: nessun messaggio pendente -> 200 ok=true, changed=0.
15+
export async function POST() {
16+
const denied = await requireAuth();
17+
if (denied) return denied;
18+
19+
// Local mode: SQLite. Lo stato verra' pushato al cloud al prossimo tick.
20+
if (await isLocalRequest()) {
21+
const ws = await getWorkspacePath();
22+
if (!ws) {
23+
return NextResponse.json(
24+
{ error: "workspace non trovato" },
25+
{ status: 500 },
26+
);
27+
}
28+
try {
29+
const changed = ackAllPendingMessagesLocal(ws);
30+
return NextResponse.json({ ok: true, changed });
31+
} catch (e) {
32+
return NextResponse.json(
33+
{ error: `ack-all fallito: ${(e as Error).message}` },
34+
{ status: 500 },
35+
);
36+
}
37+
}
38+
39+
// Cloud mode: Supabase con RLS. user_id vincola alle sole righe dell'utente.
40+
if (!isSupabaseConfigured) {
41+
return NextResponse.json(
42+
{ error: "Supabase non configurato" },
43+
{ status: 500 },
44+
);
45+
}
46+
const supabase = await createClient();
47+
const {
48+
data: { user },
49+
} = await supabase.auth.getUser();
50+
if (!user) {
51+
return NextResponse.json({ error: "Non autenticato" }, { status: 401 });
52+
}
53+
54+
const { data, error } = await supabase
55+
.from("pending_user_messages")
56+
.update({ acknowledged_at: new Date().toISOString() })
57+
.eq("user_id", user.id)
58+
.eq("delivered_via", "web")
59+
.is("acknowledged_at", null)
60+
.select("id");
61+
62+
if (error) {
63+
return NextResponse.json({ error: error.message }, { status: 500 });
64+
}
65+
return NextResponse.json({ ok: true, changed: data?.length ?? 0 });
66+
}

web/app/components/PendingMessagesCard.tsx

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,15 @@ const T: Record<string, Record<string, string>> = {
145145
fr: "Marquer comme lu",
146146
pt: "Marcar como lido",
147147
},
148+
mark_all_read: {
149+
it: "Segna tutti come letti",
150+
en: "Mark all as read",
151+
hu: "Összes megjelölése olvasottként",
152+
es: "Marcar todo como leído",
153+
de: "Alle als gelesen markieren",
154+
fr: "Tout marquer comme lu",
155+
pt: "Marcar tudo como lido",
156+
},
148157
reply: {
149158
it: "Rispondi →",
150159
en: "Reply →",
@@ -227,6 +236,15 @@ const KIND_LABEL_KEY: Record<PendingMessageKind, string> = {
227236
alert: "kind_alert",
228237
};
229238

239+
// Alcuni messaggi salvati prima del fix in jht-notify-user contengono le
240+
// escape LETTERALI `\n`/`\t` (l'agente le scrive come separatori di paragrafo
241+
// in una stringa singola). pre-wrap rende solo i newline VERI, quindi senza
242+
// questa normalizzazione si vedrebbe il testo "\n\n". Difensivo e idempotente:
243+
// i newline gia' veri restano invariati.
244+
function normalizeBody(s: string): string {
245+
return s.replace(/\\r\\n|\\n/g, "\n").replace(/\\t/g, "\t");
246+
}
247+
230248
export default function PendingMessagesCard({ initialMessages }: Props) {
231249
// L'array e' state interno: ack/reply rimuovono il messaggio dalla lista
232250
// senza dover ricaricare la pagina. Il polling per nuove notifiche verra'
@@ -273,6 +291,24 @@ export default function PendingMessagesCard({ initialMessages }: Props) {
273291
});
274292
}
275293

294+
async function handleAckAll() {
295+
setError(null);
296+
startTransition(async () => {
297+
try {
298+
const res = await fetch(`/api/pending-messages/ack-all`, {
299+
method: "POST",
300+
});
301+
if (!res.ok) {
302+
const body = await res.json().catch(() => ({}));
303+
throw new Error(body.error ?? `HTTP ${res.status}`);
304+
}
305+
setMessages([]);
306+
} catch (e) {
307+
setError((e as Error).message);
308+
}
309+
});
310+
}
311+
276312
async function handleReply(id: string) {
277313
const reply = replyText.trim();
278314
if (!reply) {
@@ -304,9 +340,21 @@ export default function PendingMessagesCard({ initialMessages }: Props) {
304340
<div className="mb-8" style={{ animation: "fade-in 0.35s ease both" }}>
305341
<div className="flex items-center justify-between mb-4">
306342
<span className="section-label">{tr("section_title")}</span>
307-
<span className="text-[10px] font-semibold tracking-widest uppercase text-[var(--color-dim)]">
308-
{tr("unread").replace("{n}", String(messages.length))}
309-
</span>
343+
<div className="flex items-center gap-3">
344+
<span className="text-[10px] font-semibold tracking-widest uppercase text-[var(--color-dim)]">
345+
{tr("unread").replace("{n}", String(messages.length))}
346+
</span>
347+
{messages.length > 1 && (
348+
<button
349+
type="button"
350+
onClick={handleAckAll}
351+
disabled={pending}
352+
className="text-[10px] font-semibold tracking-widest uppercase text-[var(--color-muted)] hover:text-[var(--color-bright)] transition-colors disabled:opacity-50"
353+
>
354+
{tr("mark_all_read")}
355+
</button>
356+
)}
357+
</div>
310358
</div>
311359

312360
{error && (
@@ -377,7 +425,7 @@ export default function PendingMessagesCard({ initialMessages }: Props) {
377425
className="text-[12px] text-[var(--color-base)] m-0 mb-2 leading-relaxed"
378426
style={{ whiteSpace: "pre-wrap" }}
379427
>
380-
{m.body}
428+
{normalizeBody(m.body)}
381429
</p>
382430
{m.related_position_id && (
383431
<Link

web/app/components/ProfileBlockRenderer.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,18 @@ type Dist = { label?: string; value?: number };
2020

2121
const asArray = (v: unknown): unknown[] => (Array.isArray(v) ? v : []);
2222

23+
// Le narrative del profilo sono LLM-generated: possono contenere escape
24+
// LETTERALI `\n`/`\t` (i due caratteri, non whitespace) che pre-wrap NON
25+
// interpreta. Le normalizziamo a render, come in PendingMessagesCard.
26+
function normalizeNarrative(s: string): string {
27+
return s.replace(/\\r\\n|\\n/g, "\n").replace(/\\t/g, "\t");
28+
}
29+
2330
function Narrative({ text }: { text: unknown }) {
2431
const s = typeof text === "string" ? text : JSON.stringify(text);
2532
return (
2633
<p className="text-[12px] text-[var(--color-bright)] leading-relaxed whitespace-pre-wrap">
27-
{s}
34+
{normalizeNarrative(s)}
2835
</p>
2936
);
3037
}

web/lib/local-queries.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1625,6 +1625,23 @@ export function ackPendingMessageLocal(ws: string, id: string): boolean {
16251625
return result.changes > 0;
16261626
}
16271627

1628+
// Marca come letti tutti i messaggi web pendenti in un colpo solo. Stesso
1629+
// filtro di getPendingMessagesLocal (delivered_via='web' AND non ack) cosi'
1630+
// azzera esattamente cio' che la dashboard mostra. Ritorna il n. di righe.
1631+
export function ackAllPendingMessagesLocal(ws: string): number {
1632+
const db = getDb(ws);
1633+
const result = db
1634+
.prepare(
1635+
`
1636+
UPDATE pending_user_messages
1637+
SET acknowledged_at = CURRENT_TIMESTAMP
1638+
WHERE delivered_via = 'web' AND acknowledged_at IS NULL
1639+
`,
1640+
)
1641+
.run();
1642+
return result.changes;
1643+
}
1644+
16281645
export function replyPendingMessageLocal(
16291646
ws: string,
16301647
id: string,

0 commit comments

Comments
 (0)