zigmini 0.8.0-beta.7 → 0.9.0-beta.1
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.ko.md +18 -5
- package/README.md +18 -5
- package/assets/agent/gateway-request-rules.md +3 -1
- package/assets/android/zigmini.apk +0 -0
- package/assets/android/zigmini.apk.json +3 -3
- package/assets/build.json +1 -1
- package/docs/manual.ko.md +50 -4
- package/docs/manual.md +48 -4
- package/host/omo-check/patches/omo-5.1.44-compaction-port.patch +2630 -0
- package/host/omo-check/patches/omo-5.1.44-gateway-port.manifest.json +329 -0
- package/host/omo-check/patches/omo-5.1.44-provider-port.patch +440 -0
- package/host/omo-check/patches/omo-5.1.44-rpc-port.patch +778 -0
- package/package.json +1 -1
- package/src/decisions.ts +32 -13
- package/src/engine/compat.json +3 -3
- package/src/hub.ts +57 -31
- package/src/main.ts +22 -7
- package/src/notification-history.ts +56 -0
- package/src/protocol.ts +2 -1
- package/src/security-allow.ts +126 -0
- package/src/security-check.ts +355 -37
- package/src/store.ts +165 -5
- package/src/work.ts +21 -7
- package/web/DESIGN.md +39 -0
- package/web/app.js +198 -69
- package/web/decisions.d.ts +1 -1
- package/web/decisions.js +3 -0
- package/web/diagram.d.ts +28 -0
- package/web/diagram.js +228 -0
- package/web/index.html +13 -0
- package/web/security-inbox.d.ts +35 -0
- package/web/security-inbox.js +237 -0
- package/web/style.css +31 -1
package/src/store.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import { OMO_CHECK_BASELINE, omoCheckActionable, omoCheckDaytime } from "./omo-check-policy.ts";
|
|
3
|
-
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { isDeepStrictEqual } from "node:util";
|
|
6
6
|
import type { ConversationInfo, DecisionAnswerRequest, NotificationMode, ReplyNotification } from "./protocol.ts";
|
|
7
7
|
import type { NativeTurnStats } from "./turn-stats.ts";
|
|
8
|
+
import { SecurityRuntimeError, securityDecision, type AllowedValue, type HeldState, type SecurityApprover, type SecurityHoldRecord, type SecurityIntent, type SecurityReceipt } from "./security-allow.ts";
|
|
8
9
|
|
|
9
10
|
export interface StoredEvent {
|
|
10
11
|
seq: number;
|
|
@@ -358,7 +359,7 @@ export interface DecisionDismissal {
|
|
|
358
359
|
}
|
|
359
360
|
|
|
360
361
|
export class DecisionStoreError extends Error {
|
|
361
|
-
constructor(readonly code: "decision_not_found" | "decision_conflict" | "submission_conflict" | "source_conflict" | "invalid_decision" | "invalid_decision_answer" | "invalid_decision_cursor" | "invalid_decision_transition", readonly decision: Decision | null = null) {
|
|
362
|
+
constructor(readonly code: "security_endpoint_required" | "decision_not_found" | "decision_conflict" | "submission_conflict" | "source_conflict" | "invalid_decision" | "invalid_decision_answer" | "invalid_decision_cursor" | "invalid_decision_transition", readonly decision: Decision | null = null) {
|
|
362
363
|
super(code);
|
|
363
364
|
}
|
|
364
365
|
}
|
|
@@ -461,9 +462,11 @@ function completeDecisionAnswer(method: DecisionMethod, payload: unknown, respon
|
|
|
461
462
|
|
|
462
463
|
export class Store {
|
|
463
464
|
private readonly db: Database;
|
|
465
|
+
private readonly securityReceiptListeners = new Set<() => void>();
|
|
464
466
|
|
|
465
467
|
constructor(dataDir: string) {
|
|
466
|
-
mkdirSync(dataDir, { recursive: true });
|
|
468
|
+
mkdirSync(dataDir, { recursive: true, mode: 0o700 });
|
|
469
|
+
chmodSync(dataDir, 0o700);
|
|
467
470
|
this.db = new Database(join(dataDir, "gateway.sqlite"), { create: true });
|
|
468
471
|
this.db.exec("PRAGMA journal_mode = WAL;");
|
|
469
472
|
// Linux builds of bun:sqlite default to synchronous=FULL, which fsyncs every appended event (~80x slower,
|
|
@@ -638,6 +641,158 @@ export class Store {
|
|
|
638
641
|
this.db.close();
|
|
639
642
|
throw error;
|
|
640
643
|
}
|
|
644
|
+
this.db.exec(`
|
|
645
|
+
CREATE TABLE IF NOT EXISTS security_allowed_values (
|
|
646
|
+
id TEXT PRIMARY KEY, value TEXT COLLATE BINARY NOT NULL UNIQUE, data TEXT NOT NULL
|
|
647
|
+
);
|
|
648
|
+
CREATE TABLE IF NOT EXISTS security_allow_revision (
|
|
649
|
+
id INTEGER PRIMARY KEY CHECK(id = 1), revision INTEGER NOT NULL
|
|
650
|
+
);
|
|
651
|
+
INSERT OR IGNORE INTO security_allow_revision VALUES (1, 0);
|
|
652
|
+
CREATE TABLE IF NOT EXISTS security_holds (
|
|
653
|
+
id TEXT PRIMARY KEY, source_key TEXT UNIQUE, intent_submission TEXT UNIQUE, data TEXT NOT NULL
|
|
654
|
+
);
|
|
655
|
+
`);
|
|
656
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
657
|
+
const path = join(dataDir, `gateway.sqlite${suffix}`);
|
|
658
|
+
if (existsSync(path)) chmodSync(path, 0o600);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
securityAllowedValues(): { revision: number; values: AllowedValue[] } {
|
|
663
|
+
return this.db.transaction(() => ({
|
|
664
|
+
revision: this.db.query<{ revision: number }, []>("SELECT revision FROM security_allow_revision WHERE id = 1").get()!.revision,
|
|
665
|
+
values: this.db.query<{ data: string }, []>("SELECT data FROM security_allowed_values ORDER BY json_extract(data, '$.approvedAt'), id").all()
|
|
666
|
+
.map((row): AllowedValue => JSON.parse(row.data)),
|
|
667
|
+
})).immediate();
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
securityHolds(): SecurityHoldRecord[] {
|
|
671
|
+
return this.db.query<{ data: string }, []>("SELECT data FROM security_holds ORDER BY rowid").all().map((row) => JSON.parse(row.data));
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
securityHold(id: string): SecurityHoldRecord | null {
|
|
675
|
+
const row = this.db.query<{ data: string }, [string]>("SELECT data FROM security_holds WHERE id = ?").get(id);
|
|
676
|
+
return row ? JSON.parse(row.data) : null;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
securityHoldByKey(key: string): SecurityHoldRecord | null {
|
|
680
|
+
const row = this.db.query<{ data: string }, [string]>("SELECT data FROM security_holds WHERE source_key = ?").get(key);
|
|
681
|
+
return row ? JSON.parse(row.data) : null;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
createSecurityHold(record: SecurityHoldRecord): void {
|
|
685
|
+
this.db.transaction(() => {
|
|
686
|
+
const s = record.state;
|
|
687
|
+
this.db.query("INSERT INTO security_holds (id, source_key, data) VALUES (?, ?, ?)").run(s.id, record.key, JSON.stringify(record));
|
|
688
|
+
this.insertDecision({
|
|
689
|
+
id: s.id, revision: s.revision, ownerConv: s.conv, scope: `security:${s.conv}`, workId: null,
|
|
690
|
+
sourceSessionPath: null, sourceRequestId: s.id, sourceCommandSeq: null, legacyActionId: null,
|
|
691
|
+
method: "confirm", permission: false, required: false, payload: { kind: "security-check", heldId: s.id },
|
|
692
|
+
bindingState: "closed", deadlineAt: null, status: "pending",
|
|
693
|
+
submissionId: null, submissionRevision: null, submissionMode: null, answer: null, commandId: null,
|
|
694
|
+
answerRoute: null, supersededBy: null, reason: null, createdAt: s.createdAt, updatedAt: s.createdAt,
|
|
695
|
+
});
|
|
696
|
+
}).immediate();
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
private writeSecurityHold(record: SecurityHoldRecord): SecurityHoldRecord {
|
|
700
|
+
const s = record.state;
|
|
701
|
+
this.db.query("UPDATE security_holds SET data = ?, intent_submission = ? WHERE id = ?")
|
|
702
|
+
.run(JSON.stringify(record), record.intent?.submissionId ?? null, s.id);
|
|
703
|
+
const statuses = { pending: "pending", processing: "submitted", sent: "delivered", cancelled: "closed", failed: "needs_review", unconfirmed: "needs_review" } as const;
|
|
704
|
+
this.db.query("UPDATE decisions SET revision = ?, status = ?, reason = ?, updated_at = ? WHERE id = ?")
|
|
705
|
+
.run(s.revision, statuses[s.status], s.errorCode, new Date().toISOString(), s.id);
|
|
706
|
+
return record;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
claimSecurityHold(id: string, intent: SecurityIntent): { record: SecurityHoldRecord; fresh: boolean } {
|
|
710
|
+
return this.db.transaction(() => {
|
|
711
|
+
const record = this.securityHold(id);
|
|
712
|
+
if (!record) throw new SecurityRuntimeError("held_conflict");
|
|
713
|
+
if (record.intent !== null) {
|
|
714
|
+
if (!isDeepStrictEqual(record.intent, intent)) throw new SecurityRuntimeError("held_conflict", record.state);
|
|
715
|
+
return { record, fresh: false };
|
|
716
|
+
}
|
|
717
|
+
const reused = intent.submissionId === null ? null : this.db.query<{ id: string }, [string]>("SELECT id FROM security_holds WHERE intent_submission = ?").get(intent.submissionId);
|
|
718
|
+
if (reused || record.state.status !== "pending" ||
|
|
719
|
+
(intent.expectedRevision !== null && record.state.revision !== intent.expectedRevision)) throw new SecurityRuntimeError("held_conflict", record.state);
|
|
720
|
+
const claimed = this.writeSecurityHold({ ...record, intent, state: {
|
|
721
|
+
...record.state, revision: record.state.revision + 1,
|
|
722
|
+
status: intent.send ? "processing" : "cancelled",
|
|
723
|
+
} });
|
|
724
|
+
return { record: claimed, fresh: true };
|
|
725
|
+
}).immediate();
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
reserveSecurityDispatch(id: string, occurrences: readonly { canonicalValue?: string; value: string; kind: string; label: string }[], approver: SecurityApprover): SecurityHoldRecord {
|
|
729
|
+
return this.db.transaction(() => {
|
|
730
|
+
const record = this.securityHold(id);
|
|
731
|
+
if (!record || record.state.status !== "processing" || record.dispatchReserved) throw new SecurityRuntimeError("held_conflict", record?.state);
|
|
732
|
+
const remember = record.intent?.remember === true;
|
|
733
|
+
let revision = this.securityAllowedValues().revision;
|
|
734
|
+
if (remember) {
|
|
735
|
+
let added = 0;
|
|
736
|
+
for (const occurrence of occurrences) {
|
|
737
|
+
const value = occurrence.canonicalValue ?? occurrence.value;
|
|
738
|
+
const entry: AllowedValue = { id: crypto.randomUUID(), value, kind: occurrence.kind, label: occurrence.label,
|
|
739
|
+
approvedAt: new Date().toISOString(), approvedBy: approver, heldId: id };
|
|
740
|
+
added += this.db.query("INSERT OR IGNORE INTO security_allowed_values VALUES (?, ?, ?)").run(entry.id, value, JSON.stringify(entry)).changes;
|
|
741
|
+
}
|
|
742
|
+
if (added > 0) {
|
|
743
|
+
this.db.query("UPDATE security_allow_revision SET revision = revision + 1 WHERE id = 1").run();
|
|
744
|
+
revision++;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
return this.writeSecurityHold({ ...record, dispatchReserved: true, state: {
|
|
748
|
+
...record.state, revision: record.state.revision + 1, remembered: remember, allowlistRevision: revision,
|
|
749
|
+
} });
|
|
750
|
+
}).immediate();
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
updateSecurityHold(id: string, change: Partial<Pick<HeldState, "status" | "errorCode">>, receipt?: SecurityReceipt, intent?: SecurityIntent): SecurityHoldRecord {
|
|
754
|
+
return this.db.transaction(() => {
|
|
755
|
+
const record = this.securityHold(id);
|
|
756
|
+
if (!record) throw new SecurityRuntimeError("held_conflict");
|
|
757
|
+
return this.writeSecurityHold({ ...record, intent: record.intent ?? intent ?? null, ...(receipt ? { receipt } : {}),
|
|
758
|
+
state: { ...record.state, ...change, revision: record.state.revision + 1 } });
|
|
759
|
+
}).immediate();
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
deleteSecurityAllowedValues(expectedRevision: number, id?: string): { revision: number; removed: number } {
|
|
763
|
+
return this.db.transaction(() => {
|
|
764
|
+
let revision = this.securityAllowedValues().revision;
|
|
765
|
+
if (revision !== expectedRevision) throw new SecurityRuntimeError("allowlist_conflict", undefined, revision);
|
|
766
|
+
const removed = id === undefined ? this.db.query("DELETE FROM security_allowed_values").run().changes
|
|
767
|
+
: this.db.query("DELETE FROM security_allowed_values WHERE id = ?").run(id).changes;
|
|
768
|
+
if (removed > 0) {
|
|
769
|
+
this.db.query("UPDATE security_allow_revision SET revision = revision + 1 WHERE id = 1").run();
|
|
770
|
+
revision++;
|
|
771
|
+
}
|
|
772
|
+
return { revision, removed };
|
|
773
|
+
}).immediate();
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
securityReceipt(ref: SecurityReceipt): "sent" | "unconfirmed" | null {
|
|
777
|
+
if (ref.kind === "answer") {
|
|
778
|
+
const decision = this.getDecision(ref.decisionId);
|
|
779
|
+
if (decision?.submissionId !== ref.submissionId) return null;
|
|
780
|
+
return decision?.status === "delivered" ? "sent" : decision?.reason === "receipt_unknown" ? "unconfirmed" : null;
|
|
781
|
+
}
|
|
782
|
+
const after = this.eventsAfter(ref.conv, ref.messageSeq);
|
|
783
|
+
const inputs = after.filter((event) => event.type === "main.input" && object(event.data) && object(event.data.input_ref) &&
|
|
784
|
+
event.data.input_ref.message_seq === ref.messageSeq);
|
|
785
|
+
return inputs.some((input) => after.some((event) => event.type === "main.input.ack" && object(event.data) &&
|
|
786
|
+
event.data.input_seq === input.seq)) ? "sent" : null;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
onSecurityReceipt(listener: () => void): () => void {
|
|
790
|
+
this.securityReceiptListeners.add(listener);
|
|
791
|
+
return () => this.securityReceiptListeners.delete(listener);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
notifySecurityReceipts(): void {
|
|
795
|
+
for (const listener of this.securityReceiptListeners) listener();
|
|
641
796
|
}
|
|
642
797
|
|
|
643
798
|
/**
|
|
@@ -939,6 +1094,7 @@ export class Store {
|
|
|
939
1094
|
.run(input.conv, data.session_path, data.handoff);
|
|
940
1095
|
}
|
|
941
1096
|
}).immediate();
|
|
1097
|
+
this.notifySecurityReceipts();
|
|
942
1098
|
}
|
|
943
1099
|
|
|
944
1100
|
eventsAfter(conv: string, seq: number, limit = -1): StoredEvent[] {
|
|
@@ -1015,11 +1171,12 @@ export class Store {
|
|
|
1015
1171
|
}).immediate();
|
|
1016
1172
|
}
|
|
1017
1173
|
|
|
1018
|
-
/** Acceptance
|
|
1019
|
-
acceptMainUser(conv: string, data: unknown): StoredEvent {
|
|
1174
|
+
/** Acceptance, unsent intent and the optional receipt link commit together before session admission. */
|
|
1175
|
+
acceptMainUser(conv: string, data: unknown, receipt?: (ref: SecurityReceipt) => void): StoredEvent {
|
|
1020
1176
|
return this.db.transaction(() => {
|
|
1021
1177
|
const message = this.appendEvent(conv, "message.user", data);
|
|
1022
1178
|
this.appendEvent(conv, "main.user.pending", { message_seq: message.seq });
|
|
1179
|
+
receipt?.({ kind: "message", conv, messageSeq: message.seq });
|
|
1023
1180
|
return message;
|
|
1024
1181
|
}).immediate();
|
|
1025
1182
|
}
|
|
@@ -1593,6 +1750,7 @@ export class Store {
|
|
|
1593
1750
|
* for sibling A cannot invalidate sibling B's options.
|
|
1594
1751
|
*/
|
|
1595
1752
|
decisionContextReason(decision: Decision): string | null {
|
|
1753
|
+
if (securityDecision(decision)) return null;
|
|
1596
1754
|
if (decision.workId !== null) {
|
|
1597
1755
|
const work = this.getWork(decision.workId);
|
|
1598
1756
|
if (!work) return "owner_unknown";
|
|
@@ -1625,6 +1783,7 @@ export class Store {
|
|
|
1625
1783
|
const result = this.db.transaction((): DecisionClaim | DecisionStoreError => {
|
|
1626
1784
|
let decision = this.getDecision(id);
|
|
1627
1785
|
if (!decision) throw new DecisionStoreError("decision_not_found");
|
|
1786
|
+
if (securityDecision(decision)) throw new DecisionStoreError("security_endpoint_required");
|
|
1628
1787
|
if (!Number.isSafeInteger(request.expectedRevision) || request.expectedRevision < 1 ||
|
|
1629
1788
|
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(request.submissionId)) {
|
|
1630
1789
|
throw new DecisionStoreError("invalid_decision_answer", decision);
|
|
@@ -2275,6 +2434,7 @@ export class Store {
|
|
|
2275
2434
|
return this.db.transaction((): DecisionDismissal => {
|
|
2276
2435
|
const d = this.getDecision(id);
|
|
2277
2436
|
if (!d) throw new DecisionStoreError("decision_not_found");
|
|
2437
|
+
if (securityDecision(d)) throw new DecisionStoreError("security_endpoint_required");
|
|
2278
2438
|
if (d.revision !== expectedRevision || d.status === "delivered" || d.status === "closed" || d.status === "submitted") {
|
|
2279
2439
|
throw new DecisionStoreError("decision_conflict", d);
|
|
2280
2440
|
}
|
package/src/work.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { isDeepStrictEqual } from "node:util";
|
|
|
4
4
|
import { OmoCommandError, type OmoClient, type OmoEvent, type OmoStatus } from "./omo/client.ts";
|
|
5
5
|
import { DecisionStoreError, Store, WorkStoreError, type LedgerState, type MainInput, type StoredEvent, type WorkCommand, type WorkItem, type WorkReport, type WorkUpdateKind } from "./store.ts";
|
|
6
6
|
import { nativeReplyEntry, type NativeReplyEntry, type NativeReplyObserver } from "./reply-notifications.ts";
|
|
7
|
+
import { notificationHistory } from "./notification-history.ts";
|
|
7
8
|
|
|
8
9
|
const ACK_TIMEOUT_MS = 30_000;
|
|
9
10
|
const PREFIX = "ZGW_WORK_COMMAND ";
|
|
@@ -126,11 +127,15 @@ export class WorkRouter {
|
|
|
126
127
|
/** Includes finished, normally lazy sources so enabling never backfills their old replies. */
|
|
127
128
|
async notificationBaseline(): Promise<void> {
|
|
128
129
|
for (const work of this.allWorks()) {
|
|
129
|
-
const
|
|
130
|
+
const generation = this.generation;
|
|
130
131
|
const revision = this.options.replies?.revision();
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
132
|
+
const current = () => !this.stopped && generation === this.generation &&
|
|
133
|
+
this.store.getWork(work.id)?.sessionPath === work.sessionPath;
|
|
134
|
+
await notificationHistory(work.sessionPath, (entry) => {
|
|
135
|
+
if (!current()) throw new Error("reply_baseline_discarded");
|
|
136
|
+
if (revision !== undefined) this.options.replies?.observe({ sessionPath: work.sessionPath, conv: work.ownerConv, workId: work.id, title: work.title }, [entry], revision);
|
|
137
|
+
}, true);
|
|
138
|
+
if (!current()) throw new Error("reply_baseline_discarded");
|
|
134
139
|
}
|
|
135
140
|
}
|
|
136
141
|
private allWorks(): WorkItem[] { return this.store.listConversations().flatMap((conv) => this.store.listWorks(conv.id)); }
|
|
@@ -750,10 +755,19 @@ export class WorkRouter {
|
|
|
750
755
|
if (!this.ready || generation !== this.generation) return;
|
|
751
756
|
// A completed report may precede the final assistant reply. Enabled notifications
|
|
752
757
|
// must recover that tail even when command delivery already reached its terminal state.
|
|
753
|
-
if (["completed", "failed", "cancelled"].includes(work.status) &&
|
|
754
|
-
this.options.replies?.settings().mode !== "all-replies" &&
|
|
755
|
-
!this.store.listWorkCommands(work.id).some((command) => ["pending", "sending", "unknown"].includes(command.delivery))) continue;
|
|
756
758
|
try {
|
|
759
|
+
if (["completed", "failed", "cancelled"].includes(work.status) &&
|
|
760
|
+
!this.store.listWorkCommands(work.id).some((command) => ["pending", "sending", "unknown"].includes(command.delivery))) {
|
|
761
|
+
if (this.options.replies?.settings().mode === "all-replies") {
|
|
762
|
+
const revision = this.options.replies.revision();
|
|
763
|
+
await notificationHistory(work.sessionPath, (entry) => {
|
|
764
|
+
if (!this.ready || generation !== this.generation ||
|
|
765
|
+
this.store.getWork(work.id)?.sessionPath !== work.sessionPath) throw new Error("reply_history_discarded");
|
|
766
|
+
this.options.replies?.observe({ sessionPath: work.sessionPath, conv: work.ownerConv, workId: work.id, title: work.title }, [entry], revision);
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
757
771
|
await this.reconcile(work.id);
|
|
758
772
|
this.schedule(work.id);
|
|
759
773
|
} catch (error) { this.fail(error); }
|
package/web/DESIGN.md
CHANGED
|
@@ -14,6 +14,10 @@ The chat retains its bounded scroll area. The memo remains an anchored,
|
|
|
14
14
|
resizable panel inside `messages-wrap`, constrained to the available width
|
|
15
15
|
and height. Text owns the remaining panel height; image controls do not
|
|
16
16
|
overlap the composer or resize handle.
|
|
17
|
+
The message viewport reserves a 64px right gutter outside its scroll area.
|
|
18
|
+
The existing 34px navigation buttons and their 20px right offset occupy this
|
|
19
|
+
gutter, with 10px clearance. All message kinds share this boundary, including
|
|
20
|
+
system notices. The message list remains the only vertical scroll owner.
|
|
17
21
|
|
|
18
22
|
## 4. Typography and spacing
|
|
19
23
|
Keep existing 12px panel labels, 14px memo text, 8/12px control spacing,
|
|
@@ -105,6 +109,16 @@ image-specific remove labels. Keyboard focus remains visible. Closing or
|
|
|
105
109
|
reopening the memo must not lose saved text or image references.
|
|
106
110
|
|
|
107
111
|
## 8. Verification and retained debt
|
|
112
|
+
Security confirmation reuses the same inbox item, text buttons and inline delete
|
|
113
|
+
confirmation. Chat and inbox share the held identity. Pending, processing, sent,
|
|
114
|
+
cancelled, failed and unconfirmed states have text labels; only confirmed sent
|
|
115
|
+
uses `--ok`. Canonical values preserve whitespace and wrap at narrow widths.
|
|
116
|
+
Allowed values remain inside the existing security settings section. The list
|
|
117
|
+
uses 12px metadata, 8px spacing, 4px radii and existing border/muted tokens.
|
|
118
|
+
Loading, offline, empty, error, retry and busy states use live status regions.
|
|
119
|
+
Delete confirmation focuses the keep action and explains future-check effects.
|
|
120
|
+
Real desktop/mobile and light/dark QA is assigned to the integration lead.
|
|
121
|
+
|
|
108
122
|
Exercise empty/one/two/full/uploading/error/remove/reopen/remote-update states
|
|
109
123
|
at narrow and wide sizes. Preserve the existing theme and unrelated controls.
|
|
110
124
|
Existing broad application-script size is outside this feature; extract only
|
|
@@ -139,3 +153,28 @@ A sent message shows the references it carried above its text as quotes: the 1px
|
|
|
139
153
|
as the title; reference text is set as DOM text only, never HTML. No new tokens or motion; autocomplete, IME, autocopy,
|
|
140
154
|
마지막 보냄 includes the reference count, including a reference-only send. ArrowUp still recalls typed text only,
|
|
141
155
|
as it already does for image-only messages.
|
|
156
|
+
|
|
157
|
+
## 10. Inline chat diagrams (180)
|
|
158
|
+
Only complete top-level, unindented triple-backtick `zigmini-diagram` fences
|
|
159
|
+
become diagrams. Invalid and unfinished fences retain the existing source view.
|
|
160
|
+
The figure uses `--bg-card`, `--bg-input`, `--border`, `--text` and `--muted`.
|
|
161
|
+
Native node text inherits the message font and stays selectable. Nodes use
|
|
162
|
+
8px/12px padding, a 4px radius and a 1px border; the figure uses an 8px radius.
|
|
163
|
+
Top-to-bottom levels use intrinsic max-content columns, 24px column gaps and
|
|
164
|
+
48px row gaps. A 24px right gutter carries edges that skip a level; their
|
|
165
|
+
horizontal segments stay in the row gaps, never through intermediate labels.
|
|
166
|
+
Labels never scale down or truncate, including at enlarged text.
|
|
167
|
+
The diagram viewport alone owns horizontal and vertical diagram scrolling,
|
|
168
|
+
bounded to the message width and 24rem height. The transcript retains normal
|
|
169
|
+
scroll chaining. At phone widths up to 640px, the figure leaves 48px on its right
|
|
170
|
+
for the existing transcript navigation buttons, keeping its source and scroll
|
|
171
|
+
controls clear without changing those buttons or other message layouts.
|
|
172
|
+
The viewport is a named keyboard-focusable region with a visible
|
|
173
|
+
text-colour focus outline. A native details/summary source foldout follows it,
|
|
174
|
+
with a 44px minimum summary target and selectable complete fence source.
|
|
175
|
+
The title and a native text list describe every directed edge for assistive
|
|
176
|
+
technology; SVG connectors are inert and hidden from it. Disconnected nodes
|
|
177
|
+
remain visible. No animation, network, remote fonts, new settings or exports.
|
|
178
|
+
Source foldouts retain their local open state across streaming updates.
|
|
179
|
+
Desktop/phone light/dark, enlarged text, source/copy and reference QA belong
|
|
180
|
+
to the parent integration lane; local unit checks do not claim visual approval.
|