@gmickel/gno 2.5.1 → 2.6.0
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 +60 -5
- package/assets/skill/README.md +5 -1
- package/assets/skill/SKILL.md +75 -1
- package/assets/skill/cli-reference.md +123 -0
- package/assets/skill/examples.md +30 -0
- package/assets/skill/mcp-reference.md +52 -0
- package/assets/skill/recipes/capture-and-file.md +6 -0
- package/assets/skill/recipes/memory-file-decision.md +6 -0
- package/assets/skill/recipes/memory-supersede-fact.md +5 -0
- package/assets/skill/recipes/session-evidence-lookup.md +98 -0
- package/assets/spa-production.json.gz +0 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v2.5.1.zip → gno-browser-clipper-v2.6.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.6.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +2 -1
- package/spec/cli.md +347 -24
- package/spec/mcp.md +198 -2
- package/spec/output-schemas/capture-receipt.schema.json +3 -0
- package/spec/output-schemas/mcp-capture-result.schema.json +3 -0
- package/spec/output-schemas/memory-remember.schema.json +8 -2
- package/spec/output-schemas/request-status.schema.json +113 -0
- package/spec/output-schemas/sessions-automation-run.schema.json +46 -0
- package/spec/output-schemas/sessions-discovery.schema.json +38 -0
- package/spec/output-schemas/sessions-import-receipt.schema.json +156 -0
- package/spec/output-schemas/sessions-status.schema.json +432 -0
- package/src/cli/commands/ask.ts +14 -2
- package/src/cli/commands/capture.ts +55 -96
- package/src/cli/commands/daemon.ts +41 -0
- package/src/cli/commands/ls.ts +3 -0
- package/src/cli/commands/memory.ts +12 -3
- package/src/cli/commands/request-status.ts +59 -0
- package/src/cli/commands/reset.ts +39 -5
- package/src/cli/commands/sessions.ts +713 -0
- package/src/cli/commands/shared.ts +14 -1
- package/src/cli/program.ts +388 -1
- package/src/cli/session-binding.ts +49 -0
- package/src/config/types.ts +8 -0
- package/src/core/capture-publish.ts +239 -0
- package/src/core/capture-sync.ts +3 -0
- package/src/core/memory-remember.ts +233 -122
- package/src/core/memory-types.ts +11 -0
- package/src/core/network-boundary-inventory.ts +8 -0
- package/src/core/request-receipts.ts +671 -0
- package/src/index.ts +9 -0
- package/src/mcp/context.ts +8 -0
- package/src/mcp/http-egress.ts +4 -0
- package/src/mcp/http-transport.ts +2 -0
- package/src/mcp/tools/capture.ts +87 -83
- package/src/mcp/tools/index.ts +66 -0
- package/src/mcp/tools/memory-remember.ts +7 -0
- package/src/mcp/tools/memory-shared.ts +7 -1
- package/src/mcp/tools/request-status.ts +73 -0
- package/src/mcp/tools/sessions.ts +208 -0
- package/src/sdk/client.ts +180 -84
- package/src/sdk/index.ts +6 -0
- package/src/sdk/types.ts +54 -2
- package/src/serve/capture-service.ts +98 -32
- package/src/serve/config-sync.ts +3 -2
- package/src/serve/public/app.tsx +4 -1
- package/src/serve/public/components/CaptureModal.tsx +26 -8
- package/src/serve/public/components/sessions/AutomationPanel.tsx +800 -0
- package/src/serve/public/components/sessions/ImportReceipt.tsx +238 -0
- package/src/serve/public/components/sessions/SessionSearch.tsx +286 -0
- package/src/serve/public/components/sessions/SourcesPanel.tsx +541 -0
- package/src/serve/public/components/sessions/api.ts +40 -0
- package/src/serve/public/components/sessions/snippet.tsx +53 -0
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/hooks/use-api.ts +10 -2
- package/src/serve/public/lib/request-intent.ts +69 -0
- package/src/serve/public/lib/workspace-actions.ts +12 -1
- package/src/serve/public/lib/workspace-tabs.ts +2 -0
- package/src/serve/public/pages/Dashboard.tsx +10 -0
- package/src/serve/public/pages/DocView.tsx +15 -1
- package/src/serve/public/pages/DocumentEditor.tsx +139 -96
- package/src/serve/public/pages/Sessions.tsx +350 -0
- package/src/serve/resident-runtime.ts +43 -3
- package/src/serve/routes/api.ts +476 -147
- package/src/serve/routes/sessions.ts +766 -0
- package/src/serve/security.ts +9 -0
- package/src/serve/server.ts +205 -1
- package/src/serve/session-automation.ts +146 -0
- package/src/sessions/archive.ts +348 -0
- package/src/sessions/automation-state.ts +444 -0
- package/src/sessions/automation-status.ts +239 -0
- package/src/sessions/automation.ts +1169 -0
- package/src/sessions/binding.ts +105 -0
- package/src/sessions/claude-hook.ts +240 -0
- package/src/sessions/config.ts +176 -0
- package/src/sessions/format.ts +191 -0
- package/src/sessions/import-child-env.ts +8 -0
- package/src/sessions/import-child.ts +152 -0
- package/src/sessions/parsers/claude-code.ts +259 -0
- package/src/sessions/parsers/codex.ts +303 -0
- package/src/sessions/parsers/hermes.ts +248 -0
- package/src/sessions/parsers/openclaw.ts +496 -0
- package/src/sessions/parsers/shared.ts +184 -0
- package/src/sessions/sanitize.ts +222 -0
- package/src/sessions/service.ts +1533 -0
- package/src/sessions/setup.ts +477 -0
- package/src/sessions/sources.ts +518 -0
- package/src/sessions/state.ts +118 -0
- package/src/sessions/types.ts +457 -0
- package/src/store/sqlite/adapter.ts +54 -15
- package/src/store/sqlite/scoped-index.ts +9 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.5.1.zip.sha256 +0 -1
|
@@ -81,7 +81,12 @@ export function useApi<T>() {
|
|
|
81
81
|
export async function apiFetch<T>(
|
|
82
82
|
endpoint: string,
|
|
83
83
|
options?: RequestInit
|
|
84
|
-
): Promise<{
|
|
84
|
+
): Promise<{
|
|
85
|
+
data: T | null;
|
|
86
|
+
error: string | null;
|
|
87
|
+
/** `error.details` of the JSON error envelope, when the server sent one. */
|
|
88
|
+
details?: Record<string, unknown>;
|
|
89
|
+
}> {
|
|
85
90
|
try {
|
|
86
91
|
const res = await fetch(endpoint, {
|
|
87
92
|
headers: { "Content-Type": "application/json" },
|
|
@@ -95,10 +100,13 @@ export async function apiFetch<T>(
|
|
|
95
100
|
}
|
|
96
101
|
|
|
97
102
|
if (!res.ok) {
|
|
98
|
-
const apiError = json as {
|
|
103
|
+
const apiError = json as {
|
|
104
|
+
error?: { message?: string; details?: Record<string, unknown> };
|
|
105
|
+
};
|
|
99
106
|
return {
|
|
100
107
|
data: null,
|
|
101
108
|
error: apiError.error?.message || `Request failed: ${res.status}`,
|
|
109
|
+
details: apiError.error?.details,
|
|
102
110
|
};
|
|
103
111
|
}
|
|
104
112
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One request ID per submitted write intent.
|
|
3
|
+
*
|
|
4
|
+
* Retrying the same payload (after a lost response, an error, or a page
|
|
5
|
+
* refresh) reuses the ID, so the server replays the committed outcome instead
|
|
6
|
+
* of writing twice. A changed payload is a new intent and gets a new ID. The
|
|
7
|
+
* unresolved intent is kept in sessionStorage so it survives a refresh of
|
|
8
|
+
* this tab; it is cleared once the write is confirmed.
|
|
9
|
+
*/
|
|
10
|
+
export interface RequestIntent {
|
|
11
|
+
key: string;
|
|
12
|
+
requestId: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Compact payload fingerprint (cyrb53); a collision only yields a conflict error. */
|
|
16
|
+
function fingerprint(text: string): string {
|
|
17
|
+
let h1 = 0xde_ad_be_ef;
|
|
18
|
+
let h2 = 0x41_c6_ce_57;
|
|
19
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
20
|
+
const code = text.charCodeAt(index);
|
|
21
|
+
h1 = Math.imul(h1 ^ code, 2_654_435_761);
|
|
22
|
+
h2 = Math.imul(h2 ^ code, 1_597_334_677);
|
|
23
|
+
}
|
|
24
|
+
h1 = Math.imul(h1 ^ (h1 >>> 16), 2_246_822_507);
|
|
25
|
+
h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3_266_489_909);
|
|
26
|
+
h2 = Math.imul(h2 ^ (h2 >>> 16), 2_246_822_507);
|
|
27
|
+
h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3_266_489_909);
|
|
28
|
+
return (4_294_967_296 * (2_097_151 & h2) + (h1 >>> 0)).toString(36);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readStored(storageKey: string): RequestIntent | null {
|
|
32
|
+
try {
|
|
33
|
+
const raw = sessionStorage.getItem(storageKey);
|
|
34
|
+
return raw ? (JSON.parse(raw) as RequestIntent) : null;
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function requestIdForIntent(
|
|
41
|
+
slot: { current: RequestIntent | null },
|
|
42
|
+
payload: string,
|
|
43
|
+
storageKey: string
|
|
44
|
+
): string {
|
|
45
|
+
const key = fingerprint(payload);
|
|
46
|
+
slot.current ??= readStored(storageKey);
|
|
47
|
+
if (slot.current?.key !== key) {
|
|
48
|
+
slot.current = { key, requestId: crypto.randomUUID() };
|
|
49
|
+
try {
|
|
50
|
+
sessionStorage.setItem(storageKey, JSON.stringify(slot.current));
|
|
51
|
+
} catch {
|
|
52
|
+
// Storage unavailable: the ID still covers retries in this page.
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return slot.current.requestId;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The intent's write is confirmed: the next submit is a new intent. */
|
|
59
|
+
export function clearRequestIntent(
|
|
60
|
+
slot: { current: RequestIntent | null },
|
|
61
|
+
storageKey: string
|
|
62
|
+
): void {
|
|
63
|
+
slot.current = null;
|
|
64
|
+
try {
|
|
65
|
+
sessionStorage.removeItem(storageKey);
|
|
66
|
+
} catch {
|
|
67
|
+
// Nothing persisted to clear.
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -14,7 +14,8 @@ export type WorkspaceActionId =
|
|
|
14
14
|
| "go-ask"
|
|
15
15
|
| "go-graph"
|
|
16
16
|
| "go-collections"
|
|
17
|
-
| "go-connectors"
|
|
17
|
+
| "go-connectors"
|
|
18
|
+
| "go-sessions";
|
|
18
19
|
|
|
19
20
|
export interface WorkspaceAction {
|
|
20
21
|
id: WorkspaceActionId;
|
|
@@ -155,6 +156,13 @@ export function getWorkspaceActions(
|
|
|
155
156
|
keywords: ["connectors", "mcp", "skills", "agents"],
|
|
156
157
|
available: true,
|
|
157
158
|
},
|
|
159
|
+
{
|
|
160
|
+
id: "go-sessions",
|
|
161
|
+
group: "Go To",
|
|
162
|
+
label: "Agent sessions",
|
|
163
|
+
keywords: ["sessions", "archive", "transcripts", "import", "agents"],
|
|
164
|
+
available: true,
|
|
165
|
+
},
|
|
158
166
|
];
|
|
159
167
|
}
|
|
160
168
|
|
|
@@ -220,6 +228,9 @@ export function runWorkspaceAction(
|
|
|
220
228
|
case "go-connectors":
|
|
221
229
|
handlers.navigate("/connectors");
|
|
222
230
|
break;
|
|
231
|
+
case "go-sessions":
|
|
232
|
+
handlers.navigate("/sessions");
|
|
233
|
+
break;
|
|
223
234
|
}
|
|
224
235
|
|
|
225
236
|
handlers.closePalette();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
ArchiveIcon,
|
|
2
3
|
BookOpen,
|
|
3
4
|
CheckCircle2Icon,
|
|
4
5
|
CpuIcon,
|
|
@@ -540,6 +541,15 @@ export default function Dashboard({ navigate }: PageProps) {
|
|
|
540
541
|
<HistoryIcon className="size-4" />
|
|
541
542
|
Trace history
|
|
542
543
|
</Button>
|
|
544
|
+
<Button
|
|
545
|
+
className="gap-2"
|
|
546
|
+
onClick={() => navigate("/sessions")}
|
|
547
|
+
size="lg"
|
|
548
|
+
variant="outline"
|
|
549
|
+
>
|
|
550
|
+
<ArchiveIcon className="size-4" />
|
|
551
|
+
Agent sessions
|
|
552
|
+
</Button>
|
|
543
553
|
<Button
|
|
544
554
|
onClick={() => navigate("/context/compiled")}
|
|
545
555
|
size="lg"
|
|
@@ -83,6 +83,13 @@ import {
|
|
|
83
83
|
isPdfDocument,
|
|
84
84
|
} from "../lib/doc-asset-url";
|
|
85
85
|
import { waitForDocumentAvailability } from "../lib/document-availability";
|
|
86
|
+
import {
|
|
87
|
+
clearRequestIntent,
|
|
88
|
+
type RequestIntent,
|
|
89
|
+
requestIdForIntent,
|
|
90
|
+
} from "../lib/request-intent";
|
|
91
|
+
|
|
92
|
+
const TAG_INTENT_KEY = "gno:tag-intent";
|
|
86
93
|
import {
|
|
87
94
|
buildReadableSectionUrl,
|
|
88
95
|
createCitationSectionUrl,
|
|
@@ -1201,7 +1208,8 @@ export default function DocView({ navigate }: PageProps) {
|
|
|
1201
1208
|
setTagSaveError(null);
|
|
1202
1209
|
}, []);
|
|
1203
1210
|
|
|
1204
|
-
// Save tags
|
|
1211
|
+
// Save tags (one request ID per tag-set intent, reused on retry)
|
|
1212
|
+
const tagIntentRef = useRef<RequestIntent | null>(null);
|
|
1205
1213
|
const handleSaveTags = useCallback(async () => {
|
|
1206
1214
|
if (!doc) return;
|
|
1207
1215
|
|
|
@@ -1218,6 +1226,11 @@ export default function DocView({ navigate }: PageProps) {
|
|
|
1218
1226
|
expectedSourceHash: doc.source.sourceHash,
|
|
1219
1227
|
expectedModifiedAt: doc.source.modifiedAt,
|
|
1220
1228
|
uri: doc.uri,
|
|
1229
|
+
requestId: requestIdForIntent(
|
|
1230
|
+
tagIntentRef,
|
|
1231
|
+
`${doc.uri}\u0000${doc.source.sourceHash}\u0000${editedTags.join(",")}`,
|
|
1232
|
+
TAG_INTENT_KEY
|
|
1233
|
+
),
|
|
1221
1234
|
}),
|
|
1222
1235
|
}
|
|
1223
1236
|
);
|
|
@@ -1229,6 +1242,7 @@ export default function DocView({ navigate }: PageProps) {
|
|
|
1229
1242
|
return;
|
|
1230
1243
|
}
|
|
1231
1244
|
|
|
1245
|
+
clearRequestIntent(tagIntentRef, TAG_INTENT_KEY);
|
|
1232
1246
|
// Update doc with new tags
|
|
1233
1247
|
setDoc({
|
|
1234
1248
|
...doc,
|
|
@@ -73,6 +73,13 @@ import {
|
|
|
73
73
|
loadLatestLocalHistory,
|
|
74
74
|
type LocalHistoryEntry,
|
|
75
75
|
} from "../lib/local-history";
|
|
76
|
+
import {
|
|
77
|
+
clearRequestIntent,
|
|
78
|
+
type RequestIntent,
|
|
79
|
+
requestIdForIntent,
|
|
80
|
+
} from "../lib/request-intent";
|
|
81
|
+
|
|
82
|
+
const SAVE_INTENT_KEY = "gno:save-intent";
|
|
76
83
|
import { getActiveWikiLinkQuery } from "../lib/wiki-link";
|
|
77
84
|
|
|
78
85
|
interface PageProps {
|
|
@@ -123,6 +130,8 @@ interface UpdateDocResponse {
|
|
|
123
130
|
sourceHash: string;
|
|
124
131
|
modifiedAt?: string;
|
|
125
132
|
};
|
|
133
|
+
/** Present when the save carried a request ID. */
|
|
134
|
+
request?: { replayed: boolean };
|
|
126
135
|
}
|
|
127
136
|
|
|
128
137
|
interface DocsAutocompleteResponse {
|
|
@@ -132,7 +141,7 @@ interface DocsAutocompleteResponse {
|
|
|
132
141
|
type SaveStatus = "saved" | "saving" | "unsaved" | "error";
|
|
133
142
|
|
|
134
143
|
function useDebouncedCallback<T extends unknown[]>(
|
|
135
|
-
callback: (...args: T) =>
|
|
144
|
+
callback: (...args: T) => unknown,
|
|
136
145
|
delay: number
|
|
137
146
|
) {
|
|
138
147
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
@@ -309,23 +318,44 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
309
318
|
}
|
|
310
319
|
}, [syncScroll]);
|
|
311
320
|
|
|
312
|
-
//
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
321
|
+
// Autosave timers and Cmd+S run later than the render that scheduled them:
|
|
322
|
+
// read the current revision and last committed content from refs.
|
|
323
|
+
const docRef = useRef(doc);
|
|
324
|
+
const committedContentRef = useRef(originalContent);
|
|
325
|
+
useEffect(() => {
|
|
326
|
+
docRef.current = doc;
|
|
327
|
+
}, [doc]);
|
|
328
|
+
// One request ID per (document revision, content) save intent: retrying a
|
|
329
|
+
// save whose response was lost replays it instead of reporting a conflict.
|
|
330
|
+
const saveIntentRef = useRef<RequestIntent | null>(null);
|
|
331
|
+
|
|
332
|
+
/** Save `contentToSave`; resolves true once it is committed on disk. */
|
|
333
|
+
const persistContent = useCallback(
|
|
334
|
+
async (contentToSave: string): Promise<boolean> => {
|
|
335
|
+
const current = docRef.current;
|
|
336
|
+
if (!current) return false;
|
|
337
|
+
if (contentToSave === committedContentRef.current) {
|
|
338
|
+
setSaveStatus("saved");
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
316
341
|
|
|
317
342
|
setSaveStatus("saving");
|
|
318
343
|
setSaveError(null);
|
|
319
344
|
|
|
320
345
|
const { data, error: err } = await apiFetch<UpdateDocResponse>(
|
|
321
|
-
`/api/docs/${encodeURIComponent(
|
|
346
|
+
`/api/docs/${encodeURIComponent(current.docid)}`,
|
|
322
347
|
{
|
|
323
348
|
method: "PUT",
|
|
324
349
|
body: JSON.stringify({
|
|
325
350
|
content: contentToSave,
|
|
326
|
-
expectedSourceHash:
|
|
327
|
-
expectedModifiedAt:
|
|
328
|
-
uri:
|
|
351
|
+
expectedSourceHash: current.source.sourceHash,
|
|
352
|
+
expectedModifiedAt: current.source.modifiedAt,
|
|
353
|
+
uri: current.uri,
|
|
354
|
+
requestId: requestIdForIntent(
|
|
355
|
+
saveIntentRef,
|
|
356
|
+
`${current.uri}\u0000${current.source.sourceHash}\u0000${contentToSave}`,
|
|
357
|
+
SAVE_INTENT_KEY
|
|
358
|
+
),
|
|
329
359
|
}),
|
|
330
360
|
}
|
|
331
361
|
);
|
|
@@ -333,32 +363,49 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
333
363
|
if (err) {
|
|
334
364
|
setSaveStatus("error");
|
|
335
365
|
setSaveError(err);
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (data?.request?.replayed) {
|
|
370
|
+
// A replay reports an earlier commit; disk may have moved on since.
|
|
371
|
+
// Clear the change notice only if the file still holds that commit.
|
|
372
|
+
const { data: latest } = await apiFetch<DocData>(
|
|
373
|
+
`/api/doc?uri=${encodeURIComponent(current.uri)}`
|
|
374
|
+
);
|
|
375
|
+
if (latest?.source.sourceHash === data.version.sourceHash) {
|
|
376
|
+
setExternalChangeNotice(null);
|
|
377
|
+
}
|
|
336
378
|
} else {
|
|
379
|
+
// Written now against the loaded revision: the next change event is
|
|
380
|
+
// this save's own sync, and any earlier notice is stale.
|
|
337
381
|
ignoreDocEventsUntilRef.current = Date.now() + 5_000;
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
}
|
|
382
|
+
setExternalChangeNotice(null);
|
|
383
|
+
}
|
|
384
|
+
const previous = committedContentRef.current;
|
|
385
|
+
if (previous !== contentToSave) {
|
|
386
|
+
appendLocalHistory(current.docid, previous);
|
|
387
|
+
refreshHistoryEntries(current.docid);
|
|
388
|
+
}
|
|
389
|
+
clearRequestIntent(saveIntentRef, SAVE_INTENT_KEY);
|
|
390
|
+
committedContentRef.current = contentToSave;
|
|
391
|
+
setSaveStatus("saved");
|
|
392
|
+
setOriginalContent(contentToSave);
|
|
393
|
+
setLastSaved(new Date());
|
|
394
|
+
if (data) {
|
|
395
|
+
const next = {
|
|
396
|
+
...current,
|
|
397
|
+
source: {
|
|
398
|
+
...current.source,
|
|
399
|
+
sourceHash: data.version.sourceHash,
|
|
400
|
+
modifiedAt: data.version.modifiedAt,
|
|
401
|
+
},
|
|
402
|
+
};
|
|
403
|
+
docRef.current = next;
|
|
404
|
+
setDoc(next);
|
|
359
405
|
}
|
|
406
|
+
return true;
|
|
360
407
|
},
|
|
361
|
-
[
|
|
408
|
+
[]
|
|
362
409
|
);
|
|
363
410
|
|
|
364
411
|
const handleCreateEditableCopy = useCallback(async () => {
|
|
@@ -476,16 +523,18 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
476
523
|
);
|
|
477
524
|
|
|
478
525
|
// Debounced auto-save
|
|
479
|
-
const { debouncedFn: debouncedSave } =
|
|
480
|
-
|
|
481
|
-
2000
|
|
482
|
-
);
|
|
526
|
+
const { debouncedFn: debouncedSave, cancel: cancelAutosave } =
|
|
527
|
+
useDebouncedCallback(persistContent, 2000);
|
|
483
528
|
|
|
484
529
|
// Handle content changes
|
|
485
530
|
const handleContentChange = useCallback(
|
|
486
531
|
(newContent: string) => {
|
|
487
532
|
setContent(newContent);
|
|
488
|
-
if (newContent
|
|
533
|
+
if (newContent === originalContent) {
|
|
534
|
+
// Back to the committed text: drop a pending autosave of a draft.
|
|
535
|
+
cancelAutosave();
|
|
536
|
+
setSaveStatus("saved");
|
|
537
|
+
} else {
|
|
489
538
|
setSaveStatus("unsaved");
|
|
490
539
|
debouncedSave(newContent);
|
|
491
540
|
}
|
|
@@ -509,7 +558,7 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
509
558
|
setWikiLinkOpen(true);
|
|
510
559
|
setWikiLinkActiveIndex(0);
|
|
511
560
|
},
|
|
512
|
-
[originalContent, debouncedSave]
|
|
561
|
+
[cancelAutosave, originalContent, debouncedSave]
|
|
513
562
|
);
|
|
514
563
|
|
|
515
564
|
useEffect(() => {
|
|
@@ -530,55 +579,15 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
530
579
|
});
|
|
531
580
|
}, [doc?.collection, wikiLinkOpen, wikiLinkQuery]);
|
|
532
581
|
|
|
533
|
-
// Force save (Cmd+S) -
|
|
582
|
+
// Force save (Cmd+S) - supersedes a pending autosave, then triggers embedding
|
|
534
583
|
const handleForceSave = useCallback(async () => {
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
// Save document
|
|
541
|
-
const { data, error: err } = await apiFetch<UpdateDocResponse>(
|
|
542
|
-
`/api/docs/${encodeURIComponent(doc.docid)}`,
|
|
543
|
-
{
|
|
544
|
-
method: "PUT",
|
|
545
|
-
body: JSON.stringify({
|
|
546
|
-
content,
|
|
547
|
-
expectedSourceHash: doc.source.sourceHash,
|
|
548
|
-
expectedModifiedAt: doc.source.modifiedAt,
|
|
549
|
-
uri: doc.uri,
|
|
550
|
-
}),
|
|
551
|
-
}
|
|
552
|
-
);
|
|
553
|
-
|
|
554
|
-
if (err) {
|
|
555
|
-
setSaveStatus("error");
|
|
556
|
-
setSaveError(err);
|
|
557
|
-
return;
|
|
584
|
+
cancelAutosave();
|
|
585
|
+
if (!hasUnsavedChanges) return;
|
|
586
|
+
if (await persistContent(content)) {
|
|
587
|
+
// Fire and forget - don't block on result
|
|
588
|
+
void apiFetch("/api/embed", { method: "POST" });
|
|
558
589
|
}
|
|
559
|
-
|
|
560
|
-
ignoreDocEventsUntilRef.current = Date.now() + 5_000;
|
|
561
|
-
if (originalContent !== content) {
|
|
562
|
-
appendLocalHistory(doc.docid, originalContent);
|
|
563
|
-
refreshHistoryEntries(doc.docid);
|
|
564
|
-
}
|
|
565
|
-
setSaveStatus("saved");
|
|
566
|
-
setOriginalContent(content);
|
|
567
|
-
setLastSaved(new Date());
|
|
568
|
-
if (data) {
|
|
569
|
-
setDoc({
|
|
570
|
-
...doc,
|
|
571
|
-
source: {
|
|
572
|
-
...doc.source,
|
|
573
|
-
sourceHash: data.version.sourceHash,
|
|
574
|
-
modifiedAt: data.version.modifiedAt,
|
|
575
|
-
},
|
|
576
|
-
});
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
// Trigger embedding (fire and forget - don't block on result)
|
|
580
|
-
void apiFetch("/api/embed", { method: "POST" });
|
|
581
|
-
}, [hasUnsavedChanges, doc, content]);
|
|
590
|
+
}, [cancelAutosave, content, hasUnsavedChanges, persistContent]);
|
|
582
591
|
|
|
583
592
|
const loadDocument = useCallback(() => {
|
|
584
593
|
const uri = currentTarget.uri;
|
|
@@ -599,6 +608,7 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
599
608
|
const docContent = data.content ?? "";
|
|
600
609
|
setContent(docContent);
|
|
601
610
|
setOriginalContent(docContent);
|
|
611
|
+
committedContentRef.current = docContent;
|
|
602
612
|
refreshHistoryEntries(data.docid);
|
|
603
613
|
// Ensure CodeMirror reflects content after async load
|
|
604
614
|
requestAnimationFrame(() => {
|
|
@@ -617,10 +627,16 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
617
627
|
loadDocument();
|
|
618
628
|
}, [loadDocument]);
|
|
619
629
|
|
|
630
|
+
// Judge each change event once; later doc updates must not re-raise it.
|
|
631
|
+
const handledDocEventRef = useRef<string | null>(null);
|
|
620
632
|
useEffect(() => {
|
|
621
633
|
if (!doc || latestDocEvent?.uri !== doc.uri) {
|
|
622
634
|
return;
|
|
623
635
|
}
|
|
636
|
+
if (handledDocEventRef.current === latestDocEvent.changedAt) {
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
handledDocEventRef.current = latestDocEvent.changedAt;
|
|
624
640
|
if (Date.now() < ignoreDocEventsUntilRef.current) {
|
|
625
641
|
return;
|
|
626
642
|
}
|
|
@@ -752,7 +768,12 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
752
768
|
|
|
753
769
|
// Save and navigate
|
|
754
770
|
const handleSaveAndNavigate = async () => {
|
|
755
|
-
|
|
771
|
+
cancelAutosave();
|
|
772
|
+
// A failed save keeps the editor (and its error) instead of losing the draft.
|
|
773
|
+
if (!(await persistContent(content))) {
|
|
774
|
+
setShowUnsavedDialog(false);
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
756
777
|
// Trigger embedding (fire and forget)
|
|
757
778
|
void apiFetch("/api/embed", { method: "POST" });
|
|
758
779
|
setShowUnsavedDialog(false);
|
|
@@ -793,9 +814,13 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
793
814
|
<TooltipProvider>
|
|
794
815
|
<Tooltip>
|
|
795
816
|
<TooltipTrigger asChild>
|
|
796
|
-
<div
|
|
797
|
-
|
|
798
|
-
|
|
817
|
+
<div
|
|
818
|
+
aria-label={text}
|
|
819
|
+
className="flex min-w-0 max-w-[16rem] shrink items-center gap-1.5 text-sm"
|
|
820
|
+
role="status"
|
|
821
|
+
>
|
|
822
|
+
<Icon className={`size-4 shrink-0 ${className}`} />
|
|
823
|
+
<span className="hidden truncate text-muted-foreground sm:inline">
|
|
799
824
|
{saveStatus === "saved" && lastSaved
|
|
800
825
|
? formatTime(lastSaved)
|
|
801
826
|
: text}
|
|
@@ -907,7 +932,7 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
907
932
|
<div className="flex h-screen flex-col overflow-hidden">
|
|
908
933
|
{/* Toolbar */}
|
|
909
934
|
<header className="glass shrink-0 border-border/50 border-b">
|
|
910
|
-
<div className="flex items-center gap-
|
|
935
|
+
<div className="flex items-center gap-2 px-2 py-2 sm:gap-3 sm:px-4">
|
|
911
936
|
{/* Home button - Scholarly Dusk brass accent */}
|
|
912
937
|
<Button
|
|
913
938
|
aria-label="Go to dashboard"
|
|
@@ -954,7 +979,7 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
954
979
|
<Separator className="h-5" orientation="vertical" />
|
|
955
980
|
|
|
956
981
|
{/* Document info */}
|
|
957
|
-
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
982
|
+
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
|
|
958
983
|
<PenIcon className="size-4 shrink-0 text-muted-foreground" />
|
|
959
984
|
<h1 className="truncate font-medium">{doc.title || doc.relPath}</h1>
|
|
960
985
|
{currentTarget.lineStart && (
|
|
@@ -968,7 +993,7 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
968
993
|
)}
|
|
969
994
|
{hasUnsavedChanges && (
|
|
970
995
|
<Badge
|
|
971
|
-
className="shrink-0 bg-yellow-500/20 text-yellow-500"
|
|
996
|
+
className="hidden shrink-0 bg-yellow-500/20 text-yellow-500 sm:inline-flex"
|
|
972
997
|
variant="outline"
|
|
973
998
|
>
|
|
974
999
|
Unsaved
|
|
@@ -982,6 +1007,7 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
982
1007
|
<Separator className="h-5" orientation="vertical" />
|
|
983
1008
|
|
|
984
1009
|
<Button
|
|
1010
|
+
className="hidden sm:inline-flex"
|
|
985
1011
|
onClick={() => {
|
|
986
1012
|
void navigator.clipboard.writeText(
|
|
987
1013
|
`${window.location.origin}${buildEditDeepLink({
|
|
@@ -999,7 +1025,7 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
999
1025
|
|
|
1000
1026
|
{/* Preview toggle */}
|
|
1001
1027
|
<Select onValueChange={setInsertPresetId} value={insertPresetId}>
|
|
1002
|
-
<SelectTrigger className="w-[180px]">
|
|
1028
|
+
<SelectTrigger className="hidden w-[180px] md:flex">
|
|
1003
1029
|
<SelectValue placeholder="Preset" />
|
|
1004
1030
|
</SelectTrigger>
|
|
1005
1031
|
<SelectContent>
|
|
@@ -1012,7 +1038,12 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
1012
1038
|
)}
|
|
1013
1039
|
</SelectContent>
|
|
1014
1040
|
</Select>
|
|
1015
|
-
<Button
|
|
1041
|
+
<Button
|
|
1042
|
+
className="hidden md:inline-flex"
|
|
1043
|
+
onClick={handleInsertPreset}
|
|
1044
|
+
size="sm"
|
|
1045
|
+
variant="outline"
|
|
1046
|
+
>
|
|
1016
1047
|
Insert preset
|
|
1017
1048
|
</Button>
|
|
1018
1049
|
|
|
@@ -1047,7 +1078,7 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
1047
1078
|
aria-label={
|
|
1048
1079
|
syncScroll ? "Disable scroll sync" : "Enable scroll sync"
|
|
1049
1080
|
}
|
|
1050
|
-
className="transition-all duration-200"
|
|
1081
|
+
className="hidden transition-all duration-200 sm:inline-flex"
|
|
1051
1082
|
onClick={() => setSyncScroll(!syncScroll)}
|
|
1052
1083
|
size="sm"
|
|
1053
1084
|
variant={syncScroll ? "secondary" : "ghost"}
|
|
@@ -1070,15 +1101,18 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
1070
1101
|
|
|
1071
1102
|
{/* Save button */}
|
|
1072
1103
|
<Button
|
|
1104
|
+
aria-label="History"
|
|
1105
|
+
className="shrink-0"
|
|
1073
1106
|
disabled={!hasLocalSnapshot}
|
|
1074
1107
|
onClick={() => setHistoryDialogOpen(true)}
|
|
1075
1108
|
size="sm"
|
|
1076
1109
|
variant="outline"
|
|
1077
1110
|
>
|
|
1078
|
-
<HistoryIcon className="mr-1.5
|
|
1079
|
-
History
|
|
1111
|
+
<HistoryIcon className="size-4 sm:mr-1.5" />
|
|
1112
|
+
<span className="hidden sm:inline">History</span>
|
|
1080
1113
|
</Button>
|
|
1081
1114
|
<Button
|
|
1115
|
+
className="shrink-0"
|
|
1082
1116
|
disabled={!hasUnsavedChanges || saveStatus === "saving"}
|
|
1083
1117
|
onClick={handleForceSave}
|
|
1084
1118
|
size="sm"
|
|
@@ -1093,6 +1127,15 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
1093
1127
|
</div>
|
|
1094
1128
|
</header>
|
|
1095
1129
|
|
|
1130
|
+
{saveStatus === "error" && saveError && (
|
|
1131
|
+
<p
|
|
1132
|
+
className="border-destructive/30 border-b bg-destructive/10 px-4 py-2 text-destructive text-sm sm:hidden"
|
|
1133
|
+
role="alert"
|
|
1134
|
+
>
|
|
1135
|
+
{saveError}
|
|
1136
|
+
</p>
|
|
1137
|
+
)}
|
|
1138
|
+
|
|
1096
1139
|
{externalChangeNotice && (
|
|
1097
1140
|
<div className="border-amber-500/30 border-b bg-amber-500/10 px-4 py-3">
|
|
1098
1141
|
<div className="flex flex-wrap items-center justify-between gap-3">
|