zigmini 1.0.0-beta.3 → 1.1.0-beta.2
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 +29 -2
- package/README.md +29 -2
- package/assets/agent/gateway-request-rules.md +9 -2
- package/assets/agent/gateway-rules.md +13 -6
- package/assets/android/zigmini.apk +0 -0
- package/assets/android/zigmini.apk.json +3 -3
- package/assets/build.json +1 -1
- package/assets/document-reader/document-reader +0 -0
- package/assets/document-reader/host-selector +0 -0
- package/assets/document-reader/manifest.json +27 -0
- package/docs/manual.ko.md +114 -10
- package/docs/manual.md +117 -11
- package/host/document-reader/DocumentRead.swift +370 -0
- package/host/document-reader/HostSelector.swift +192 -0
- package/host/document-reader/Protocol.swift +647 -0
- package/host/document-reader/main.swift +22 -0
- package/host/omo-bridge/agent-aside-tool.ts +119 -0
- package/host/omo-bridge/agent-aside.ts +522 -0
- package/host/omo-bridge/agent-collaboration-files.ts +282 -0
- package/host/omo-bridge/agent-collaboration.ts +385 -0
- package/host/omo-bridge/agent-control.ts +400 -28
- package/host/omo-bridge/agent-execution.sb +42 -0
- package/host/omo-bridge/agent-execution.ts +158 -0
- package/host/omo-bridge/agent-tool-adapters.ts +388 -0
- package/host/omo-bridge/agent-tool-permissions.ts +96 -0
- package/host/omo-bridge/aside-tabs.ts +41 -0
- package/host/omo-bridge/attachment-read.ts +100 -0
- package/host/omo-bridge/bridge.ts +10 -0
- package/host/omo-bridge/managed-call.ts +46 -0
- package/host/omo-bridge/task-control.ts +39 -5
- package/host/omo-check/patches/attachments-293/README.md +138 -0
- package/host/omo-check/patches/attachments-293/document-content.js +234 -0
- package/host/omo-check/patches/attachments-293/document-content.types.txt +91 -0
- package/host/omo-check/patches/attachments-293/omo-5.1.51-documents.patch +831 -0
- package/host/omo-check/patches/attachments-293/omo-5.1.51-gateway-port.manifest.json +471 -0
- package/host/omo-check/patches/omo-5.1.51-agent-tool-authority.patch +471 -0
- package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +28 -6
- package/host/omo-check/patches/omo-5.1.51-memory-receipts.patch +5 -0
- package/package.json +5 -3
- package/src/agent/api.ts +345 -20
- package/src/agent/artifacts.ts +196 -0
- package/src/agent/aside-target-routes.ts +369 -0
- package/src/agent/aside-targets.ts +215 -0
- package/src/agent/collaboration-admission.ts +47 -0
- package/src/agent/collaboration-read.ts +117 -0
- package/src/agent/collaboration-session.ts +196 -0
- package/src/agent/collaboration-types.ts +258 -0
- package/src/agent/collaboration.ts +399 -0
- package/src/agent/definition.ts +65 -3
- package/src/agent/fields.ts +9 -3
- package/src/agent/file-core.ts +325 -0
- package/src/agent/files.ts +87 -8
- package/src/agent/list.ts +3 -3
- package/src/agent/managed-call.ts +144 -0
- package/src/agent/tool-catalog.ts +184 -0
- package/src/agent/tool-delivery.ts +359 -0
- package/src/agent/tool-permission-types.ts +81 -0
- package/src/agent/tool-permissions.ts +1366 -0
- package/src/attachments/api.ts +161 -0
- package/src/attachments/document-reader.ts +325 -0
- package/src/attachments/host-selector.ts +410 -0
- package/src/attachments/managed-binding.ts +83 -0
- package/src/attachments/prepare.ts +214 -0
- package/src/attachments/selector-port.ts +28 -0
- package/src/attachments/service.ts +729 -0
- package/src/attachments/store.ts +698 -0
- package/src/attachments/types.ts +123 -0
- package/src/cli/doctor.ts +30 -10
- package/src/decisions.ts +109 -10
- package/src/engine/apply-patch.ts +21 -0
- package/src/engine/compat.json +2 -1
- package/src/engine/install.ts +45 -4
- package/src/engine/sandbox-cell.ts +2 -0
- package/src/gc.ts +33 -225
- package/src/housekeeping/api.ts +163 -0
- package/src/housekeeping/audit.ts +61 -0
- package/src/housekeeping/collectors/agent-collaboration.ts +33 -0
- package/src/housekeeping/collectors/agent-drafts.ts +43 -0
- package/src/housekeeping/collectors/agent-file-trash.ts +36 -0
- package/src/housekeeping/collectors/agent-trash.ts +28 -0
- package/src/housekeeping/collectors/attachment-cleanup.ts +35 -0
- package/src/housekeeping/collectors/db-backup.ts +25 -0
- package/src/housekeeping/collectors/docker-build-cache.ts +29 -0
- package/src/housekeeping/collectors/docker-image.ts +135 -0
- package/src/housekeeping/collectors/omo-version.ts +32 -0
- package/src/housekeeping/collectors/orphan-test-process.ts +205 -0
- package/src/housekeeping/collectors/reconcile-work.ts +55 -0
- package/src/housekeeping/collectors/rpc-daemon.ts +39 -0
- package/src/housekeeping/collectors/shared.ts +149 -0
- package/src/housekeeping/collectors/temp-dir.ts +28 -0
- package/src/housekeeping/collectors/update-file.ts +25 -0
- package/src/housekeeping/collectors/worktree.ts +193 -0
- package/src/housekeeping/engine.ts +125 -0
- package/src/housekeeping/reconcile/work.ts +130 -0
- package/src/housekeeping/registry.ts +63 -0
- package/src/housekeeping/trash.ts +59 -0
- package/src/housekeeping/types.ts +31 -0
- package/src/hub.ts +1095 -41
- package/src/main.ts +228 -30
- package/src/message-references.ts +14 -5
- package/src/profile-prompt.ts +13 -1
- package/src/protocol.ts +41 -6
- package/src/security-allow.ts +79 -2
- package/src/security-check.ts +1 -1
- package/src/store.ts +1946 -23
- package/src/test-crash.ts +20 -0
- package/src/work-watch.ts +55 -7
- package/src/work.ts +192 -3
- package/web/DESIGN.md +33 -0
- package/web/agents.d.ts +189 -12
- package/web/agents.js +898 -39
- package/web/app.js +2634 -210
- package/web/attachments.d.ts +91 -0
- package/web/attachments.js +422 -0
- package/web/collaboration.d.ts +87 -0
- package/web/collaboration.js +290 -0
- package/web/decisions.d.ts +39 -2
- package/web/decisions.js +354 -2
- package/web/gc.d.ts +26 -5
- package/web/gc.js +113 -13
- package/web/i18n/agents.d.ts +4 -0
- package/web/i18n/agents.js +200 -8
- package/web/i18n/attachments.js +78 -0
- package/web/i18n/decisions.d.ts +4 -0
- package/web/i18n/decisions.js +68 -0
- package/web/i18n/gc.d.ts +4 -0
- package/web/i18n/gc.js +184 -2
- package/web/i18n/main.js +80 -8
- package/web/i18n/tour.d.ts +4 -0
- package/web/i18n/tour.js +37 -0
- package/web/index.html +20 -3
- package/web/profile.d.ts +2 -2
- package/web/profile.js +11 -6
- package/web/style.css +342 -21
- package/web/tooltip.d.ts +2 -0
- package/web/tooltip.js +12 -0
- package/web/tour.d.ts +57 -0
- package/web/tour.js +450 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
export interface AttachmentSpan {
|
|
2
|
+
readonly start: number;
|
|
3
|
+
readonly end: number;
|
|
4
|
+
readonly query: string;
|
|
5
|
+
readonly raw: string;
|
|
6
|
+
}
|
|
7
|
+
export interface OwnerDocument {
|
|
8
|
+
readonly id: string;
|
|
9
|
+
readonly grantId: string;
|
|
10
|
+
readonly name: string;
|
|
11
|
+
readonly format: "html" | "md" | "txt" | "pdf";
|
|
12
|
+
readonly bytes: number;
|
|
13
|
+
readonly sha256: string;
|
|
14
|
+
readonly readMode: "text" | "pages" | "ocr" | "native-document";
|
|
15
|
+
/** Per-grant state the server adds to message frames (absent on older servers). */
|
|
16
|
+
readonly grantRevoked?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface AttachmentItem {
|
|
19
|
+
key: string;
|
|
20
|
+
id?: string;
|
|
21
|
+
revision: number;
|
|
22
|
+
state: string;
|
|
23
|
+
removed?: boolean;
|
|
24
|
+
eventRevision?: number;
|
|
25
|
+
readonly [field: string]: unknown;
|
|
26
|
+
}
|
|
27
|
+
export interface AttachmentDraft {
|
|
28
|
+
readonly conv: string;
|
|
29
|
+
readonly id: string;
|
|
30
|
+
items: AttachmentItem[];
|
|
31
|
+
submission: null | { id: string; state: string };
|
|
32
|
+
notice: string;
|
|
33
|
+
}
|
|
34
|
+
export interface AttachmentDrafts {
|
|
35
|
+
get(conv: string): AttachmentDraft;
|
|
36
|
+
reset(conv: string): AttachmentDraft;
|
|
37
|
+
update(event: { readonly conv: string; readonly data: unknown }): boolean;
|
|
38
|
+
ready(draft: AttachmentDraft): boolean;
|
|
39
|
+
}
|
|
40
|
+
export function attachmentQuery(text: string, caret: number): AttachmentSpan | null;
|
|
41
|
+
export function replaceAttachmentQuery(text: string, span: AttachmentSpan | null): string;
|
|
42
|
+
export function hostFilesSupported(snapshot: unknown): boolean;
|
|
43
|
+
export function validDocument(value: unknown): value is OwnerDocument;
|
|
44
|
+
export function validAttachmentStatus(value: unknown): boolean;
|
|
45
|
+
export function visibleOwnerText(value: unknown): string;
|
|
46
|
+
export function createAttachmentDrafts(uuid?: () => string): AttachmentDrafts;
|
|
47
|
+
export class HostFileError extends Error { readonly code: string; constructor(code: string); }
|
|
48
|
+
export function hostFileRequest(path: string, body?: unknown): Promise<unknown>;
|
|
49
|
+
/** Ports of the sent document block: the chat's confirm dialog and the authenticated request; tests inject both. */
|
|
50
|
+
export interface DocumentActions {
|
|
51
|
+
readonly request: (path: string, body?: unknown) => Promise<unknown>;
|
|
52
|
+
readonly confirm: (text: string) => boolean;
|
|
53
|
+
}
|
|
54
|
+
export const documentActions: DocumentActions;
|
|
55
|
+
export function sentDocumentBlock(documents: unknown, actions?: DocumentActions): HTMLUListElement;
|
|
56
|
+
export interface HostFileUI {
|
|
57
|
+
readonly input: HTMLTextAreaElement;
|
|
58
|
+
readonly menu: HTMLElement;
|
|
59
|
+
readonly tray: HTMLElement;
|
|
60
|
+
readonly note: HTMLElement;
|
|
61
|
+
readonly button: HTMLButtonElement;
|
|
62
|
+
}
|
|
63
|
+
export interface HostFileContext {
|
|
64
|
+
readonly conv: () => string;
|
|
65
|
+
readonly conversations: () => readonly string[];
|
|
66
|
+
readonly request: (path: string, body?: unknown) => Promise<unknown>;
|
|
67
|
+
readonly closeSlash: () => void;
|
|
68
|
+
readonly selected: (draft: AttachmentDraft, span: AttachmentSpan | null) => void;
|
|
69
|
+
readonly retry: (draft: AttachmentDraft) => void;
|
|
70
|
+
readonly changed?: () => void;
|
|
71
|
+
readonly locked?: () => boolean;
|
|
72
|
+
}
|
|
73
|
+
export interface HostFileComposer {
|
|
74
|
+
readonly drafts: AttachmentDrafts;
|
|
75
|
+
readonly visible: boolean;
|
|
76
|
+
readonly composing: boolean;
|
|
77
|
+
current(): AttachmentDraft;
|
|
78
|
+
render(): void;
|
|
79
|
+
refuseSend(reason: "limit" | "pending"): void;
|
|
80
|
+
close(): void;
|
|
81
|
+
open(span?: AttachmentSpan | null): Promise<void>;
|
|
82
|
+
choose(index: number): Promise<void>;
|
|
83
|
+
composition(active: boolean): void;
|
|
84
|
+
input(): void;
|
|
85
|
+
key(event: Pick<KeyboardEvent, "key" | "keyCode" | "isComposing" | "shiftKey">): boolean;
|
|
86
|
+
snapshot(data: unknown, connected?: boolean): void;
|
|
87
|
+
disconnect(): void;
|
|
88
|
+
event(event: { readonly conv: string; readonly data: unknown }): void;
|
|
89
|
+
ready(draft?: AttachmentDraft): boolean;
|
|
90
|
+
}
|
|
91
|
+
export function mountHostFiles(ui: HostFileUI, context: HostFileContext): HostFileComposer;
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import { registerStrings, t } from "./i18n.js";
|
|
2
|
+
import strings from "./i18n/attachments.js";
|
|
3
|
+
registerStrings(strings);
|
|
4
|
+
|
|
5
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
6
|
+
const SHA = /^[0-9a-f]{64}$/;
|
|
7
|
+
const MODES = ["text", "pages", "ocr", "native-document"];
|
|
8
|
+
const FORMATS = ["html", "md", "txt", "pdf"];
|
|
9
|
+
const positive = n => Number.isSafeInteger(n) && n > 0;
|
|
10
|
+
const LETTER = /^\p{L}$/u;
|
|
11
|
+
const terminal = state => ["discarded", "revoked", "bound"].includes(state);
|
|
12
|
+
const MAX_DOCUMENTS = 4;
|
|
13
|
+
|
|
14
|
+
/** Only an explicit, unquoted query before this caret is a selector, never a pasted path. */
|
|
15
|
+
export function attachmentQuery(text, caret) {
|
|
16
|
+
const prefix = text.slice(0, caret);
|
|
17
|
+
const match = /(?:^|\s)@([^@\n]*)$/.exec(prefix);
|
|
18
|
+
if (!match) return null;
|
|
19
|
+
const start = prefix.length - match[1].length - 1;
|
|
20
|
+
let quote = "";
|
|
21
|
+
for (let i = 0; i < start; i++) {
|
|
22
|
+
if (text[i] === "\\") { i++; continue; }
|
|
23
|
+
if (text[i] === "'" && !quote && /[\p{L}\p{N}]/u.test(text[i - 1] ?? "")) continue;
|
|
24
|
+
if ("\"'`".includes(text[i])) {
|
|
25
|
+
if (quote === text[i]) quote = "";
|
|
26
|
+
else if (!quote) quote = text[i];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (quote || /["'`\\]/.test(match[1])) return null;
|
|
30
|
+
return { start, end: caret, query: match[1], raw: prefix.slice(start) };
|
|
31
|
+
}
|
|
32
|
+
export function replaceAttachmentQuery(text, span) {
|
|
33
|
+
if (!span || text.slice(span.start, span.end) !== span.raw) return text;
|
|
34
|
+
return text.slice(0, span.start) + text.slice(span.end);
|
|
35
|
+
}
|
|
36
|
+
export function hostFilesSupported(snapshot) {
|
|
37
|
+
return snapshot?.capabilities?.hostFileAttachments === 1 && snapshot.notifyOnly !== true;
|
|
38
|
+
}
|
|
39
|
+
export function validDocument(d) {
|
|
40
|
+
return !!d && UUID.test(d.id) && UUID.test(d.grantId) && typeof d.name === "string" && d.name.length > 0
|
|
41
|
+
&& FORMATS.includes(d.format) && positive(d.bytes) && d.bytes <= 10485760 && SHA.test(d.sha256) && MODES.includes(d.readMode);
|
|
42
|
+
}
|
|
43
|
+
export function visibleOwnerText(data) {
|
|
44
|
+
return data?.attachmentFallback === true && Array.isArray(data.attachments) && data.attachments.length > 0
|
|
45
|
+
&& data.attachments.every(validDocument) ? "" : data?.text ?? "";
|
|
46
|
+
}
|
|
47
|
+
export function validAttachmentStatus(d) {
|
|
48
|
+
if (!d || !UUID.test(d.id) || typeof d.name !== "string" || !FORMATS.includes(d.format)
|
|
49
|
+
|| !positive(d.bytes) || d.bytes > 10485760 || !positive(d.revision)) return false;
|
|
50
|
+
const p = d.preparation;
|
|
51
|
+
if (!p || !Number.isSafeInteger(p.revision) || p.revision < 0) return false;
|
|
52
|
+
switch (d.state) {
|
|
53
|
+
case "snapshotting": return d.sha256 === null && d.readMode === null && p.revision === 0 && p.state === "pending";
|
|
54
|
+
case "sealed": return SHA.test(d.sha256) && d.readMode === null && p.state === "pending";
|
|
55
|
+
case "preparing": return SHA.test(d.sha256) && d.readMode === null && positive(p.revision) && p.state === "pending";
|
|
56
|
+
case "ready": case "bound": case "revoked":
|
|
57
|
+
return SHA.test(d.sha256) && MODES.includes(d.readMode) && positive(p.revision) && p.state === "ready";
|
|
58
|
+
case "error": case "discarded":
|
|
59
|
+
if (d.state === "error" && typeof d.error !== "string") return false;
|
|
60
|
+
if (d.sha256 === null && p.revision !== 0) return false;
|
|
61
|
+
return (d.sha256 === null || SHA.test(d.sha256)) && (d.readMode === null
|
|
62
|
+
? ["pending", "error", "encoding_required"].includes(p.state) && (d.state !== "error" || p.state !== "pending")
|
|
63
|
+
: SHA.test(d.sha256) && MODES.includes(d.readMode) && positive(p.revision) && p.state === "ready");
|
|
64
|
+
default: return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** Mutable state is deliberately owned by exact conversation + draft, not the visible input. */
|
|
68
|
+
export function createAttachmentDrafts(uuid = () => crypto.randomUUID()) {
|
|
69
|
+
const drafts = new Map();
|
|
70
|
+
return {
|
|
71
|
+
get(conv) {
|
|
72
|
+
if (!drafts.has(conv)) drafts.set(conv, { conv, id: uuid(), items: [], submission: null, notice: "" });
|
|
73
|
+
return drafts.get(conv);
|
|
74
|
+
},
|
|
75
|
+
reset(conv) { drafts.delete(conv); return this.get(conv); },
|
|
76
|
+
update(event) {
|
|
77
|
+
const draft = drafts.get(event.conv);
|
|
78
|
+
const data = event.data;
|
|
79
|
+
if (!draft || draft.id !== data?.draftId) return false;
|
|
80
|
+
const item = draft.items.find(item => item.id === data.attachmentId);
|
|
81
|
+
if (!item || terminal(item.state) || !positive(data.revision) || data.revision <= item.revision) return false;
|
|
82
|
+
// Events have no original SHA: fetch authenticated status before readiness can enable Send.
|
|
83
|
+
item.eventRevision = data.revision;
|
|
84
|
+
return true;
|
|
85
|
+
},
|
|
86
|
+
ready(draft) { return draft.items.length > 0 && draft.items.every(item => !item.removed && item.state === "ready" && validAttachmentStatus(item)); },
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
export class HostFileError extends Error {
|
|
90
|
+
constructor(code) { super(code); this.code = code; }
|
|
91
|
+
}
|
|
92
|
+
export async function hostFileRequest(path, body) {
|
|
93
|
+
const res = await fetch(path, { method: body === undefined ? "GET" : "POST", credentials: "same-origin",
|
|
94
|
+
headers: { "X-Zigmini-Host-Files": "1", ...(body === undefined ? {} : { "content-type": "application/json" }) },
|
|
95
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(30000) });
|
|
96
|
+
const data = await res.json();
|
|
97
|
+
if (!res.ok) throw new HostFileError(typeof data.error === "string" ? data.error : `HTTP ${res.status}`);
|
|
98
|
+
return data;
|
|
99
|
+
}
|
|
100
|
+
const node = (tag, text, cls = "") => {
|
|
101
|
+
const el = document.createElement(tag); el.className = cls; if (text !== undefined) el.textContent = text; return el;
|
|
102
|
+
};
|
|
103
|
+
function action(text, run, disabled = false) {
|
|
104
|
+
const button = node("button", text, "text-btn"); button.type = "button"; button.disabled = disabled;
|
|
105
|
+
button.onclick = run; return button;
|
|
106
|
+
}
|
|
107
|
+
/** Known codes translate; an unknown server or transport code shows generic bilingual text, never the raw code. */
|
|
108
|
+
const reason = (code, fallback = "documents.unknownError") => {
|
|
109
|
+
const key = `documents.${code}`; return t(key) === key ? t(fallback) : t(key);
|
|
110
|
+
};
|
|
111
|
+
/** A refused row explains itself with its own entry reason; gateway-wide and chip states never leak into it. */
|
|
112
|
+
const entryReason = code => {
|
|
113
|
+
const key = `documents.entry.${code}`; return t(key) === key ? t("documents.disabled") : t(key);
|
|
114
|
+
};
|
|
115
|
+
/** A grant's revision starts at 1 and moves only when the grant is revoked (store: attachment_grants revision). */
|
|
116
|
+
const GRANT_INITIAL_REVISION = 1;
|
|
117
|
+
const DELIVERIES = ["consumed", "uncertain", "already_dispatched", "cancelled"];
|
|
118
|
+
/** Answers after which the file is gone for good: the chip settles as unavailable and offers nothing further. */
|
|
119
|
+
const SETTLED = new Set(["attachment_tombstoned", "attachment_reference_unavailable", "attachment_revoked"]);
|
|
120
|
+
/** The sent block's ports: the chat's own confirm dialog and the authenticated request; tests inject both. */
|
|
121
|
+
export const documentActions = { request: (path, body) => hostFileRequest(path, body), confirm: text => globalThis.confirm(text) };
|
|
122
|
+
const summary = (d, ...extra) => [d.format.toUpperCase(), `${d.bytes} B`, reason(d.readMode), ...extra].filter(Boolean).join(" · ");
|
|
123
|
+
export function sentDocumentBlock(documents, actions = documentActions) {
|
|
124
|
+
const list = node("ul", undefined, "document-list");
|
|
125
|
+
for (const d of (Array.isArray(documents) ? documents : []).filter(validDocument)) list.append(sentDocumentRow(d, actions));
|
|
126
|
+
return list;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* The preserved user message keeps its chip. Revoke is the owner's own action, separate from discard and cleanup (C5): it
|
|
130
|
+
* reads the current state, revokes the grant at its revision, then shows the precise outcome and what revocation cannot
|
|
131
|
+
* take back (Q16). A revision conflict is re-read as state, never guessed.
|
|
132
|
+
*/
|
|
133
|
+
function sentDocumentRow(d, actions) {
|
|
134
|
+
const row = node("li", undefined, "document-chip");
|
|
135
|
+
const link = node("a", d.name); link.href = `/api/attachments/${encodeURIComponent(d.id)}/download`; link.download = d.name;
|
|
136
|
+
const meta = node("span", summary(d));
|
|
137
|
+
const revoke = action(t("documents.revoke"), () => void run());
|
|
138
|
+
revoke.setAttribute("aria-label", t("documents.revokeLabel", { name: d.name })); revoke.title = revoke.getAttribute("aria-label");
|
|
139
|
+
row.append(link, meta, revoke);
|
|
140
|
+
const read = () => actions.request(`/api/attachments/${encodeURIComponent(d.id)}`);
|
|
141
|
+
const settle = (state, note) => {
|
|
142
|
+
meta.textContent = summary(d, state); row.className = "document-chip revoked";
|
|
143
|
+
row.replaceChildren(node("strong", d.name), meta, ...(note ? [node("span", note, "document-caveat")] : []));
|
|
144
|
+
};
|
|
145
|
+
const unavailable = receipt => settle(t("documents.revoked"), [receipt ? t(`documents.delivery.${receipt.pendingDelivery}`) : "", t("documents.revokeCaveat")].filter(Boolean).join(" "));
|
|
146
|
+
async function run() {
|
|
147
|
+
if (!actions.confirm(t("documents.revokeConfirm", { name: d.name }))) return;
|
|
148
|
+
revoke.disabled = true; meta.textContent = summary(d, t("documents.revoking"));
|
|
149
|
+
try {
|
|
150
|
+
const status = await read();
|
|
151
|
+
if (!validAttachmentStatus(status) || status.id !== d.id) throw new HostFileError("invalid_attachment_status");
|
|
152
|
+
if (status.state === "revoked") { unavailable(null); return; }
|
|
153
|
+
const receipt = await actions.request(`/api/attachments/${encodeURIComponent(d.id)}/revoke`, { grantId: d.grantId, expectedRevision: GRANT_INITIAL_REVISION });
|
|
154
|
+
if (receipt?.grantId !== d.grantId || !positive(receipt.revision) || !DELIVERIES.includes(receipt.pendingDelivery) || !Array.isArray(receipt.limits)) throw new HostFileError("invalid_revoke_receipt");
|
|
155
|
+
unavailable(receipt);
|
|
156
|
+
} catch (e) {
|
|
157
|
+
if (!(e instanceof Error)) throw e;
|
|
158
|
+
const code = e.code ?? e.message;
|
|
159
|
+
if (code === "attachment_cas_conflict") {
|
|
160
|
+
const latest = await read().catch(() => null);
|
|
161
|
+
if (latest?.state === "revoked") { unavailable(null); return; }
|
|
162
|
+
}
|
|
163
|
+
if (SETTLED.has(code)) { settle(reason(code), ""); return; }
|
|
164
|
+
meta.textContent = summary(d, reason(code)); revoke.disabled = false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// The server reports each message's own grant (B1-R); a same-file grant of another message is judged by its own field.
|
|
168
|
+
// An older server sends no field, and the chip then behaves as before.
|
|
169
|
+
if (d.grantRevoked === true) unavailable(null);
|
|
170
|
+
return row;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// allow: SIZE_OK - the scoped picker state machine and its DOM adapter are kept in the single authorized browser module.
|
|
174
|
+
export function mountHostFiles(ui, context) {
|
|
175
|
+
const drafts = createAttachmentDrafts();
|
|
176
|
+
let supported = false, online = false, composing = false, queryComposing = false, committed = false, serial = 0, picker = null, painting = false;
|
|
177
|
+
const current = () => drafts.get(context.conv());
|
|
178
|
+
const notify = () => { render(); context.changed?.(); };
|
|
179
|
+
const error = (draft, e) => { if (!(e instanceof Error)) throw e; draft.notice = reason(e.code ?? e.message); notify(); };
|
|
180
|
+
/** A closed menu leaves no combobox popup and no textbox reference to it; the textarea stays a plain textbox. */
|
|
181
|
+
function close() {
|
|
182
|
+
serial++; picker = null; queryComposing = false; ui.menu.hidden = true; ui.menu.replaceChildren();
|
|
183
|
+
ui.button.setAttribute("aria-expanded", "false"); ui.input.removeAttribute("aria-controls"); ui.input.removeAttribute("aria-activedescendant");
|
|
184
|
+
}
|
|
185
|
+
async function refresh(draft, item) {
|
|
186
|
+
const result = await context.request(`/api/attachments/${encodeURIComponent(item.id)}`);
|
|
187
|
+
if (!draft.items.includes(item) || item.removed || !validAttachmentStatus(result) || result.id !== item.id
|
|
188
|
+
|| result.revision < item.revision || result.revision < (item.eventRevision ?? 0)) return;
|
|
189
|
+
if (result.state === "ready" && draft.notice === reason(item.error)) draft.notice = "";
|
|
190
|
+
delete item.error;
|
|
191
|
+
Object.assign(item, result); notify();
|
|
192
|
+
}
|
|
193
|
+
function forget(draft, item) {
|
|
194
|
+
if (draft.notice === reason(item.error)) draft.notice = "";
|
|
195
|
+
draft.items = draft.items.filter(i => i !== item);
|
|
196
|
+
// The limit notice describes the count, so it ends with it. This is the only place a draft's count falls.
|
|
197
|
+
if (draft.notice === t("documents.limit") && draft.items.length < MAX_DOCUMENTS) draft.notice = "";
|
|
198
|
+
notify();
|
|
199
|
+
}
|
|
200
|
+
async function discard(draft, item) {
|
|
201
|
+
item.removed = true;
|
|
202
|
+
// No server record yet: a settled chip leaves at once; a busy one waits for its receipt (then discards) or its failure.
|
|
203
|
+
if (!item.id) { if (!item.busy) forget(draft, item); else notify(); return; }
|
|
204
|
+
// The owner's remove always takes the chip out of the draft, so it can neither block Send nor be bound. The server
|
|
205
|
+
// record is released best-effort and never deleted here (C8): on failure it stays unbound against the staging quota.
|
|
206
|
+
forget(draft, item);
|
|
207
|
+
try {
|
|
208
|
+
const latest = await context.request(`/api/attachments/${encodeURIComponent(item.id)}`);
|
|
209
|
+
if (!validAttachmentStatus(latest) || latest.id !== item.id) throw new HostFileError("invalid_attachment_status");
|
|
210
|
+
if (latest.state === "discarded") return;
|
|
211
|
+
await context.request(`/api/attachments/${encodeURIComponent(item.id)}/discard`, { expectedRevision: latest.revision });
|
|
212
|
+
} catch (e) {
|
|
213
|
+
if ((e?.code ?? e?.message) === "attachment_not_found") return;
|
|
214
|
+
draft.notice = t("documents.discardFailed"); notify();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
async function select(draft, item) {
|
|
218
|
+
if (item.busy) return;
|
|
219
|
+
item.busy = true; item.state = "selecting"; notify();
|
|
220
|
+
try {
|
|
221
|
+
const receipt = await context.request(`/api/host-file-pickers/${encodeURIComponent(item.pickerId)}/select`,
|
|
222
|
+
{ entryToken: item.entryToken, operationId: item.key });
|
|
223
|
+
if (!UUID.test(receipt.attachmentId) || !positive(receipt.revision)) throw new HostFileError("invalid_selection_receipt");
|
|
224
|
+
Object.assign(item, { id: receipt.attachmentId, revision: receipt.revision, state: receipt.state });
|
|
225
|
+
if (item.removed || !draft.items.includes(item)) { await discard(draft, item); return; }
|
|
226
|
+
await refresh(draft, item);
|
|
227
|
+
} catch (e) { item.state = "error"; item.error = e instanceof Error ? e.message : "selection_failed"; error(draft, e); }
|
|
228
|
+
finally { item.busy = false; notify(); }
|
|
229
|
+
}
|
|
230
|
+
function refuseSend(reason) {
|
|
231
|
+
current().notice = t(reason === "limit" ? "documents.limit" : "documents.pending");
|
|
232
|
+
notify();
|
|
233
|
+
}
|
|
234
|
+
async function choose(index) {
|
|
235
|
+
const p = picker, row = p?.entries[index], draft = current();
|
|
236
|
+
if (!p || !row?.selectable || draft.submission || composing || queryComposing || context.locked?.()) return;
|
|
237
|
+
if (row.kind === "directory") { await enter(row.entryToken); return; }
|
|
238
|
+
if (draft.items.length >= MAX_DOCUMENTS) { draft.notice = t("documents.limit"); notify(); return; }
|
|
239
|
+
const item = { key: crypto.randomUUID(), pickerId: p.id, entryToken: row.entryToken,
|
|
240
|
+
name: row.name, bytes: row.size, state: "selecting", revision: 0, removed: false };
|
|
241
|
+
draft.items.push(item); close(); ui.input.focus();
|
|
242
|
+
// The selector text is consumed now, so a receipt arriving later never touches what the owner types meanwhile.
|
|
243
|
+
context.selected(draft, p.span);
|
|
244
|
+
await select(draft, item);
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Entering a folder consumes the query: the folder lists unfiltered, the search box empties, and a typed selector keeps
|
|
248
|
+
* only its "@" (what is typed next searches this folder). The span takes that shape first, so the composer's input
|
|
249
|
+
* event for the rewrite finds nothing changed and lists nothing itself.
|
|
250
|
+
*/
|
|
251
|
+
async function enter(directory) {
|
|
252
|
+
const p = picker, span = p?.span; if (!p) return;
|
|
253
|
+
p.query = "";
|
|
254
|
+
if (span?.query) { p.span = { ...span, end: span.start + 1, query: "", raw: "@" }; context.selected(current(), { ...span, start: span.start + 1, raw: span.raw.slice(1) }); }
|
|
255
|
+
await list(directory);
|
|
256
|
+
}
|
|
257
|
+
async function list(directory, cursor) {
|
|
258
|
+
const p = picker; if (!p) return;
|
|
259
|
+
const ticket = ++serial; p.loading = true; p.notice = ""; renderMenu();
|
|
260
|
+
try {
|
|
261
|
+
const result = await context.request(`/api/host-file-pickers/${encodeURIComponent(p.id)}/list`,
|
|
262
|
+
{ directoryToken: directory, query: p.query, showHidden: p.hidden, ...(cursor ? { cursor } : {}) });
|
|
263
|
+
if (picker !== p || ticket !== serial) return;
|
|
264
|
+
if (!Array.isArray(result.entries) || result.entries.length > 50 || !result.entries.every(e =>
|
|
265
|
+
typeof e.entryToken === "string" && typeof e.name === "string" && typeof e.selectable === "boolean"
|
|
266
|
+
&& ["file", "directory", "disabled"].includes(e.kind))) throw new HostFileError("invalid_picker_page");
|
|
267
|
+
p.entries = result.entries;
|
|
268
|
+
p.directory = directory; p.parent = result.parentToken; p.next = result.nextCursor; p.index = p.entries.findIndex(e => e.selectable);
|
|
269
|
+
} catch (e) { if (picker === p && ticket === serial) { if (!(e instanceof Error)) throw e; p.notice = reason(e.code ?? e.message); } }
|
|
270
|
+
finally { if (picker === p && ticket === serial) { p.loading = false; renderMenu(); } }
|
|
271
|
+
}
|
|
272
|
+
async function open(span = attachmentQuery(ui.input.value, ui.input.selectionStart)) {
|
|
273
|
+
if (!supported || !online || composing || current().submission || context.locked?.()) return;
|
|
274
|
+
if (span && [...span.query].length > 200) { current().notice = t("documents.queryLong"); notify(); return; }
|
|
275
|
+
if (picker?.conv === context.conv()) {
|
|
276
|
+
// Only a changed query lists again; the composer's input event for a consumed selector finds nothing to do.
|
|
277
|
+
const query = span?.query ?? "", changed = query !== picker.query;
|
|
278
|
+
picker.span = span; picker.query = query;
|
|
279
|
+
if (picker.id && changed) await list(picker.directory);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const draft = current(), ticket = ++serial;
|
|
283
|
+
picker = { conv: draft.conv, span, query: span?.query ?? "", hidden: false, entries: [], index: -1, loading: true }; renderMenu();
|
|
284
|
+
try {
|
|
285
|
+
const result = await context.request("/api/host-file-pickers", { convId: draft.conv, draftId: draft.id });
|
|
286
|
+
if (ticket !== serial || context.conv() !== draft.conv) return;
|
|
287
|
+
if (typeof result.pickerId !== "string" || typeof result.currentDirectoryToken !== "string" || !Array.isArray(result.rootEntries)) throw new HostFileError("invalid_picker");
|
|
288
|
+
Object.assign(picker, { id: result.pickerId, roots: result.rootEntries, directory: result.currentDirectoryToken });
|
|
289
|
+
await list(picker.directory);
|
|
290
|
+
} catch (e) { if (ticket === serial) { close(); error(draft, e); } }
|
|
291
|
+
}
|
|
292
|
+
function renderMenu() {
|
|
293
|
+
const p = picker; if (!p) return;
|
|
294
|
+
lift();
|
|
295
|
+
const focus = ui.menu.contains(document.activeElement) ? document.activeElement : null;
|
|
296
|
+
if (focus) p.focusTarget = { id: focus.id, tag: focus.tagName, text: focus.textContent, caret: focus.selectionStart };
|
|
297
|
+
else if (document.activeElement === ui.input) p.focusTarget = null;
|
|
298
|
+
ui.menu.hidden = false; ui.button.setAttribute("aria-expanded", "true");
|
|
299
|
+
const nav = node("div", undefined, "host-file-nav");
|
|
300
|
+
nav.append(node("strong", t("documents.title")), action(t("documents.close"), () => { close(); ui.input.focus(); }));
|
|
301
|
+
const queryLabel = node("label", t("documents.query"), "host-file-query-label");
|
|
302
|
+
const query = node("input"); query.id = "host-file-query"; query.type = "text"; query.value = p.query;
|
|
303
|
+
query.setAttribute("role", "combobox"); query.setAttribute("aria-autocomplete", "list"); query.setAttribute("aria-expanded", "true"); query.setAttribute("aria-controls", "host-file-list");
|
|
304
|
+
queryLabel.append(query); nav.append(queryLabel);
|
|
305
|
+
query.oninput = ev => {
|
|
306
|
+
if (picker !== p || ev.isComposing || queryComposing) return;
|
|
307
|
+
if ([...query.value].length > 200) { p.notice = t("documents.queryLong"); renderMenu(); return; }
|
|
308
|
+
p.query = query.value; void list(p.directory);
|
|
309
|
+
};
|
|
310
|
+
query.oncompositionstart = () => { if (picker === p) queryComposing = true; };
|
|
311
|
+
query.oncompositionend = () => { if (picker === p) { queryComposing = false; committed = true; query.dispatchEvent(new Event("input")); } };
|
|
312
|
+
query.onkeydown = ev => { if (api.key(ev)) { ev.preventDefault(); ev.stopPropagation(); if (ev.key === "Escape") ui.input.focus(); } };
|
|
313
|
+
query.onkeyup = ev => { if (!LETTER.test(ev.key ?? "")) committed = false; };
|
|
314
|
+
for (const root of (p.roots ?? [])) nav.append(action(root.name === "Home" ? t("documents.home") : root.name === "Computer" ? t("documents.computer") : root.name, () => void enter(root.entryToken), p.loading));
|
|
315
|
+
if (p.parent) nav.append(action(t("documents.parent"), () => void enter(p.parent), p.loading));
|
|
316
|
+
const hidden = node("label", t("documents.hidden")); const toggle = node("input"); toggle.id = "host-file-hidden"; toggle.type = "checkbox"; toggle.checked = p.hidden;
|
|
317
|
+
toggle.onchange = () => { p.hidden = toggle.checked; void list(p.directory); }; toggle.disabled = !p.id; hidden.prepend(toggle); nav.append(hidden);
|
|
318
|
+
const rows = node("div"); rows.id = "host-file-list"; rows.setAttribute("role", "listbox"); rows.setAttribute("aria-label", t("documents.title"));
|
|
319
|
+
p.entries.forEach((entry, i) => {
|
|
320
|
+
const row = node("div", undefined, "host-file-option"); row.id = `host-file-option-${i}`;
|
|
321
|
+
row.setAttribute("role", "option"); row.setAttribute("aria-selected", String(i === p.index)); row.setAttribute("aria-disabled", String(!entry.selectable || p.loading));
|
|
322
|
+
const name = node("span", `${entry.kind === "directory" ? "▸ " : ""}${entry.name}`); name.title = entry.name; row.append(name);
|
|
323
|
+
if (!entry.selectable) row.append(node("small", entryReason(entry.reason)));
|
|
324
|
+
row.onpointerdown = ev => { ev.preventDefault(); if (!p.loading) void choose(i); }; rows.append(row);
|
|
325
|
+
});
|
|
326
|
+
const status = node("div", p.loading ? t("documents.loading") : p.notice || (p.entries.length ? "" : t("documents.empty")), "host-file-status"); status.setAttribute("role", "status");
|
|
327
|
+
if (p.next) nav.append(action(t("documents.next"), () => void list(p.directory, p.next), p.loading));
|
|
328
|
+
if (p.notice) nav.append(action(t("documents.retry"), () => void list(p.directory)));
|
|
329
|
+
painting = true; ui.menu.replaceChildren(nav, rows, status);
|
|
330
|
+
if (p.focusTarget && (!p.loading || p.focusTarget.id)) {
|
|
331
|
+
const target = [...nav.querySelectorAll("button,input")].find(el => p.focusTarget.id ? el.id === p.focusTarget.id : el.tagName === p.focusTarget.tag && el.textContent === p.focusTarget.text);
|
|
332
|
+
target?.focus({ preventScroll: true });
|
|
333
|
+
if (target?.id === "host-file-query" && p.focusTarget.caret !== null) target.setSelectionRange(p.focusTarget.caret, p.focusTarget.caret);
|
|
334
|
+
}
|
|
335
|
+
painting = false;
|
|
336
|
+
ui.input.setAttribute("aria-controls", rows.id);
|
|
337
|
+
if (!p.loading && p.index >= 0) { ui.input.setAttribute("aria-activedescendant", `host-file-option-${p.index}`); query.setAttribute("aria-activedescendant", `host-file-option-${p.index}`); rows.children[p.index]?.scrollIntoView({ block: "nearest" }); }
|
|
338
|
+
else ui.input.removeAttribute("aria-activedescendant");
|
|
339
|
+
}
|
|
340
|
+
async function prepare(draft, item, encoding) {
|
|
341
|
+
item.prepareOperation ??= { encoding, key: crypto.randomUUID(), revision: item.revision };
|
|
342
|
+
if (item.prepareOperation.encoding !== encoding) item.prepareOperation = { encoding, key: crypto.randomUUID(), revision: item.revision };
|
|
343
|
+
const op = item.prepareOperation; item.busy = true; notify();
|
|
344
|
+
try { await context.request(`/api/attachments/${encodeURIComponent(item.id)}/prepare`, { encoding, operationId: op.key, expectedRevision: op.revision }); await refresh(draft, item); }
|
|
345
|
+
catch (e) { error(draft, e); } finally { item.busy = false; notify(); }
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Both menus float above whatever stands between the composer and the log (tray chips, the limit notice), never over
|
|
349
|
+
* it, and never past the top of the screen: their room is what is left above that edge (less the 6px gap and an 8px
|
|
350
|
+
* margin), so a long list scrolls inside the menu instead of pushing its header out of view.
|
|
351
|
+
*/
|
|
352
|
+
function lift() {
|
|
353
|
+
const host = ui.menu.parentElement;
|
|
354
|
+
if (!host?.getBoundingClientRect) return;
|
|
355
|
+
const top = host.getBoundingClientRect().top;
|
|
356
|
+
const above = [ui.tray, ui.note].filter(el => el.offsetParent !== null).map(el => el.getBoundingClientRect().top);
|
|
357
|
+
const edge = Math.min(top, ...above);
|
|
358
|
+
host.style.setProperty("--menu-lift", `${top - edge}px`);
|
|
359
|
+
host.style.setProperty("--menu-room", `${Math.max(0, edge - 6 - 8)}px`);
|
|
360
|
+
}
|
|
361
|
+
function render() {
|
|
362
|
+
const draft = current(); ui.button.disabled = !supported || !online || !!draft.submission || !!context.locked?.();
|
|
363
|
+
ui.button.textContent = t("documents.button"); ui.button.title = !online ? t("documents.offline") : !supported ? t("documents.unsupported") : t("documents.title");
|
|
364
|
+
ui.note.textContent = !online ? t("documents.offline") : !supported ? t("documents.unsupported") : draft.notice;
|
|
365
|
+
ui.tray.replaceChildren();
|
|
366
|
+
for (const item of draft.items) {
|
|
367
|
+
const chip = node("li", undefined, "document-chip"); chip.dataset.attachmentKey = item.key;
|
|
368
|
+
chip.append(node("strong", item.name), node("span", [item.format?.toUpperCase(), item.bytes === undefined ? "" : `${item.bytes} B`, reason(item.state), item.readMode ? reason(item.readMode) : "", item.error ? reason(item.error) : ""].filter(Boolean).join(" · ")));
|
|
369
|
+
const locked = !!draft.submission || item.busy;
|
|
370
|
+
if (item.state === "error") chip.append(action(t("documents.retry"), () => void (item.id ? refresh(draft, item).catch(e => error(draft, e)) : select(draft, item)), locked));
|
|
371
|
+
if (item.id && item.format && item.format !== "pdf" && !terminal(item.state)) {
|
|
372
|
+
const encoding = node("select"); encoding.setAttribute("aria-label", t("documents.encoding", { name: item.name })); encoding.disabled = locked;
|
|
373
|
+
encoding.append(new Option(t("documents.chooseEncoding"), ""));
|
|
374
|
+
for (const e of ["utf-8", "utf-16le", "utf-16be", "euc-kr", "cp949"]) encoding.append(new Option(e, e));
|
|
375
|
+
encoding.onchange = () => { if (encoding.value) void prepare(draft, item, encoding.value); }; chip.append(encoding);
|
|
376
|
+
}
|
|
377
|
+
const remove = action("×", () => void discard(draft, item), !!draft.submission || !!(item.removed && item.busy)); remove.setAttribute("aria-label", t("documents.remove", { name: item.name })); remove.title = remove.getAttribute("aria-label"); chip.append(remove); ui.tray.append(chip);
|
|
378
|
+
}
|
|
379
|
+
if (draft.submission) ui.tray.append(action(t("documents.retrySubmission"), () => context.retry(draft), !supported || !online || draft.submission.state !== "uncertain"));
|
|
380
|
+
lift();
|
|
381
|
+
}
|
|
382
|
+
ui.input.setAttribute("aria-autocomplete", "list"); ui.button.setAttribute("aria-expanded", "false"); ui.button.setAttribute("aria-controls", "host-file-menu");
|
|
383
|
+
ui.button.onclick = () => { context.closeSlash(); ui.input.focus(); void open(null); };
|
|
384
|
+
ui.menu.addEventListener("pointerdown", ev => { if (ev.target.closest("[role=option]")) ev.preventDefault(); });
|
|
385
|
+
ui.menu.addEventListener("keydown", ev => { if (ev.key === "Escape") { ev.preventDefault(); close(); ui.input.focus(); } });
|
|
386
|
+
ui.menu.addEventListener("focusout", ev => { if (!painting && ev.relatedTarget !== ui.input && !ui.menu.contains(ev.relatedTarget)) close(); });
|
|
387
|
+
ui.input.addEventListener("keyup", ev => { if (!LETTER.test(ev.key ?? "")) committed = false; });
|
|
388
|
+
ui.input.addEventListener("blur", () => { committed = false; });
|
|
389
|
+
if (typeof ResizeObserver === "function") { const sizes = new ResizeObserver(lift); sizes.observe(ui.tray); sizes.observe(ui.note); }
|
|
390
|
+
if (typeof window === "object" && typeof window.addEventListener === "function") window.addEventListener("resize", lift);
|
|
391
|
+
const api = {
|
|
392
|
+
drafts, current, render: notify, refuseSend, close, open, choose,
|
|
393
|
+
get visible() { return !!picker; },
|
|
394
|
+
get composing() { return composing || queryComposing; },
|
|
395
|
+
composition(active) { composing = active; committed = !active; if (active) close(); else this.input(); },
|
|
396
|
+
input() { if (composing) return; const span = attachmentQuery(ui.input.value, ui.input.selectionStart); if (span) { context.closeSlash(); void open(span); } else close(); },
|
|
397
|
+
key(ev) {
|
|
398
|
+
if (composing || queryComposing || ev.isComposing || ev.keyCode === 229 || ev.shiftKey) return false;
|
|
399
|
+
// The key that ends an IME composition can arrive after compositionend with isComposing false (Safari, WebKit 165004;
|
|
400
|
+
// Chrome on macOS with Korean input sends a second keydown). Only while this menu owns the keys (open, or an @ listing
|
|
401
|
+
// pending) does that key merely finish the composition; otherwise it is left to the composer. The flag set at
|
|
402
|
+
// compositionend is cleared by a non-letter keyup (a rolled-over letter keyup is not the committing key), by blur, or
|
|
403
|
+
// by the next distinct keydown; no timer is involved.
|
|
404
|
+
if (committed) { committed = false; if (picker) { if (ev.key === "Enter") return true; if (ev.key === "Tab") { close(); return false; } } }
|
|
405
|
+
if (!picker) return false;
|
|
406
|
+
if (ev.key === "Escape") { close(); return true; }
|
|
407
|
+
if (ev.key === "ArrowDown" || ev.key === "ArrowUp") {
|
|
408
|
+
const indices = picker.entries.flatMap((e, i) => e.selectable ? [i] : []);
|
|
409
|
+
if (indices.length) picker.index = indices[(indices.indexOf(picker.index) + (ev.key === "ArrowDown" ? 1 : -1) + indices.length) % indices.length];
|
|
410
|
+
renderMenu(); return true;
|
|
411
|
+
}
|
|
412
|
+
if (ev.key === "Tab" && (picker.loading || picker.index < 0)) { close(); return false; }
|
|
413
|
+
if (ev.key === "Enter" || ev.key === "Tab") { if (!picker.loading) void choose(picker.index); return true; }
|
|
414
|
+
return false;
|
|
415
|
+
},
|
|
416
|
+
snapshot(data, connected = true) { supported = hostFilesSupported(data); online = connected; close(); notify(); },
|
|
417
|
+
disconnect() { online = false; supported = false; close(); for (const conv of context.conversations()) { const s = drafts.get(conv).submission; if (s) s.state = "uncertain"; } notify(); },
|
|
418
|
+
event(event) { if (drafts.update(event)) { const draft = drafts.get(event.conv), item = draft.items.find(i => i.id === event.data.attachmentId); void refresh(draft, item).catch(e => error(draft, e)); } },
|
|
419
|
+
ready(draft = current()) { return supported && online && (draft.items.length === 0 || drafts.ready(draft)); },
|
|
420
|
+
};
|
|
421
|
+
return api;
|
|
422
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export const REQUESTS_PATH: string;
|
|
2
|
+
export const ARTIFACTS_PATH: string;
|
|
3
|
+
export const REQUEST_STATES: readonly RequestState[];
|
|
4
|
+
export const TERMINAL_STATES: readonly RequestState[];
|
|
5
|
+
|
|
6
|
+
export type RequestState = "queued" | "running" | "waiting_peer" | "waiting_owner" | "result_preparing" | "completed" | "failed" | "interrupted" | "cancel_requested" | "cancelled" | "unresolved_delivery" | "version_conflict";
|
|
7
|
+
export type RequestKind = "task" | "question";
|
|
8
|
+
export type StateTone = "ok" | "busy" | "warn" | "danger";
|
|
9
|
+
|
|
10
|
+
export interface CardActor { readonly kind: "main" | "agent"; readonly id: string | null }
|
|
11
|
+
export interface ResultFile { readonly artifactId: string; readonly name: string; readonly bytes: number | null; readonly sha256: string | null; readonly mediaType: string | null }
|
|
12
|
+
export interface CardResult { readonly resultId: string | null; readonly text: string; readonly files: ResultFile[] }
|
|
13
|
+
/** One request as the open conversation knows it from its events and the owner listing. */
|
|
14
|
+
export interface Card {
|
|
15
|
+
readonly requestId: string;
|
|
16
|
+
readonly rootId: string | null;
|
|
17
|
+
readonly kind: RequestKind | null;
|
|
18
|
+
readonly text: string | null;
|
|
19
|
+
readonly files: string[];
|
|
20
|
+
readonly actor: CardActor | null;
|
|
21
|
+
/** The recipient agent of a sent request, from the owner listing; null until listed (F3 D5). */
|
|
22
|
+
readonly targetAgent: string | null;
|
|
23
|
+
readonly outgoing: boolean | null;
|
|
24
|
+
readonly seq: number | null;
|
|
25
|
+
readonly ts: string | null;
|
|
26
|
+
readonly state: string | null;
|
|
27
|
+
readonly revision: number | null;
|
|
28
|
+
readonly resultId: string | null;
|
|
29
|
+
readonly result: CardResult | null;
|
|
30
|
+
readonly replied: boolean;
|
|
31
|
+
readonly blocked: string | null;
|
|
32
|
+
readonly deliveryUnknown: boolean;
|
|
33
|
+
readonly listed: boolean;
|
|
34
|
+
}
|
|
35
|
+
export type Cards = Map<string, Card>;
|
|
36
|
+
|
|
37
|
+
export interface StoredEventLike { readonly type: string; readonly seq?: number; readonly ts?: string; readonly conv?: string; readonly data?: unknown }
|
|
38
|
+
export interface EventOutcome { readonly cards: Cards; readonly requestId: string | null; readonly change: "created" | "updated" | null; readonly notice: string | null }
|
|
39
|
+
|
|
40
|
+
/** True for the gateway's C-4 `notice {kind:"collaboration",requestId,direction}`, which the card already shows. */
|
|
41
|
+
export function isCollaborationFallback(event: StoredEventLike | null | undefined): boolean;
|
|
42
|
+
|
|
43
|
+
export interface CardNote { readonly text: string; readonly error: boolean }
|
|
44
|
+
export interface CardView {
|
|
45
|
+
readonly requestId: string;
|
|
46
|
+
readonly outgoing: boolean;
|
|
47
|
+
readonly from: string;
|
|
48
|
+
readonly to: string;
|
|
49
|
+
readonly ts: string | null;
|
|
50
|
+
readonly title: string;
|
|
51
|
+
readonly kind: RequestKind | null;
|
|
52
|
+
readonly kindLabel: string | null;
|
|
53
|
+
readonly state: string | null;
|
|
54
|
+
readonly stateLabel: string;
|
|
55
|
+
readonly tone: StateTone;
|
|
56
|
+
readonly uncertain: boolean;
|
|
57
|
+
readonly text: string;
|
|
58
|
+
readonly inputs: { readonly artifactId: string; readonly name: string; readonly bytes: null; readonly sha256: null }[];
|
|
59
|
+
readonly result: { readonly text: string; readonly files: (ResultFile & { readonly size: string })[] } | null;
|
|
60
|
+
readonly replied: boolean;
|
|
61
|
+
readonly cancellable: boolean;
|
|
62
|
+
readonly notes: CardNote[];
|
|
63
|
+
}
|
|
64
|
+
export interface CardContext { readonly agents: readonly { readonly id: string; readonly name: string }[] | null; readonly agentId: string | null; readonly mainName: string }
|
|
65
|
+
|
|
66
|
+
export interface ApiRequest { readonly method: string; readonly path: string; readonly headers?: Record<string, string>; readonly body?: string }
|
|
67
|
+
export interface CancelOutcome { readonly ok: boolean; readonly state: string | null; readonly revision: number | null; readonly line: string }
|
|
68
|
+
export interface Outcome { readonly ok: boolean; readonly line: string }
|
|
69
|
+
export interface TargetOption { readonly id: string; readonly name: string; readonly checked: boolean; readonly missing: boolean | null }
|
|
70
|
+
export interface FileOriginTag { readonly origin: "user-upload" | "collaboration-copy"; readonly text: string; readonly tip: string }
|
|
71
|
+
|
|
72
|
+
export function isTerminal(state: unknown): boolean;
|
|
73
|
+
export function emptyCards(): Cards;
|
|
74
|
+
export function applyCollaborationEvent(cards: Cards, event: StoredEventLike): EventOutcome;
|
|
75
|
+
export function mergeRequestListing(cards: Cards, listing: unknown): { readonly cards: Cards; readonly changed: string[] };
|
|
76
|
+
export function stateTone(state: string | null): StateTone;
|
|
77
|
+
export function stateLabel(state: string | null): string;
|
|
78
|
+
export function cardView(card: Card, ctx: CardContext): CardView;
|
|
79
|
+
export function cancelRequest(card: Card | null | undefined): ApiRequest | null;
|
|
80
|
+
export function listingRequest(conv: string): ApiRequest;
|
|
81
|
+
export function artifactPath(artifactId: string): string;
|
|
82
|
+
export function cancelOutcome(status: number, body: unknown): CancelOutcome;
|
|
83
|
+
export function downloadOutcome(name: string, status: number, body: unknown, digests: { readonly expected: string | null; readonly header: string | null; readonly computed: string | null }): Outcome;
|
|
84
|
+
export function listingOutcome(status: number, body: unknown): Outcome;
|
|
85
|
+
export function targetOptions(agents: unknown, selfId: string | null, saved: unknown): { readonly loaded: boolean; readonly options: TargetOption[] };
|
|
86
|
+
export function fileOriginLabel(row: { readonly origin?: string | null } | null | undefined): FileOriginTag | null;
|
|
87
|
+
export function fileDeleteText(row: { readonly origin?: string | null } | null | undefined): string;
|