@shivaedev/work-board 0.14.2 → 0.15.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.
Files changed (196) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +1 -1
  3. package/assets/agent.js +1 -1
  4. package/assets/native.js +25 -25
  5. package/assets/react.js +38 -37
  6. package/dist/browser/diagnostics/schema.d.ts +15 -12
  7. package/dist/browser/diagnostics/schema.d.ts.map +1 -1
  8. package/dist/browser/diagnostics/schema.js +12 -2
  9. package/dist/browser/diagnostics/schema.js.map +1 -1
  10. package/dist/browser/views/remaining.d.ts +20 -12
  11. package/dist/browser/views/remaining.d.ts.map +1 -1
  12. package/dist/client/client.d.ts +1 -0
  13. package/dist/client/client.d.ts.map +1 -1
  14. package/dist/client/client.js +1 -1
  15. package/dist/client/client.js.map +1 -1
  16. package/dist/client/formStatus.d.ts +12 -0
  17. package/dist/client/formStatus.d.ts.map +1 -0
  18. package/dist/client/formStatus.js +27 -0
  19. package/dist/client/formStatus.js.map +1 -0
  20. package/dist/client/react/BoardShell.d.ts.map +1 -1
  21. package/dist/client/react/BoardShell.js +15 -21
  22. package/dist/client/react/BoardShell.js.map +1 -1
  23. package/dist/client/react/DocumentContext.d.ts.map +1 -1
  24. package/dist/client/react/DocumentContext.js +6 -3
  25. package/dist/client/react/DocumentContext.js.map +1 -1
  26. package/dist/client/react/FormContext.d.ts +5 -0
  27. package/dist/client/react/FormContext.d.ts.map +1 -0
  28. package/dist/client/react/FormContext.js +10 -0
  29. package/dist/client/react/FormContext.js.map +1 -0
  30. package/dist/client/react/LiveControls.d.ts +4 -3
  31. package/dist/client/react/LiveControls.d.ts.map +1 -1
  32. package/dist/client/react/LiveControls.js +5 -19
  33. package/dist/client/react/LiveControls.js.map +1 -1
  34. package/dist/client/react/MainLocationContext.d.ts +3 -0
  35. package/dist/client/react/MainLocationContext.d.ts.map +1 -0
  36. package/dist/client/react/MainLocationContext.js +7 -0
  37. package/dist/client/react/MainLocationContext.js.map +1 -0
  38. package/dist/client/react/ProjectionFrame.d.ts +1 -2
  39. package/dist/client/react/ProjectionFrame.d.ts.map +1 -1
  40. package/dist/client/react/ProjectionFrame.js +10 -4
  41. package/dist/client/react/ProjectionFrame.js.map +1 -1
  42. package/dist/client/react/ReadingLibrary.d.ts.map +1 -1
  43. package/dist/client/react/ReadingLibrary.js +7 -8
  44. package/dist/client/react/ReadingLibrary.js.map +1 -1
  45. package/dist/client/react/SystemPage.d.ts.map +1 -1
  46. package/dist/client/react/SystemPage.js +2 -1
  47. package/dist/client/react/SystemPage.js.map +1 -1
  48. package/dist/client/react/TodayOptions.d.ts.map +1 -1
  49. package/dist/client/react/TodayOptions.js +23 -14
  50. package/dist/client/react/TodayOptions.js.map +1 -1
  51. package/dist/client/react/VisualViewer.d.ts.map +1 -1
  52. package/dist/client/react/VisualViewer.js +6 -5
  53. package/dist/client/react/VisualViewer.js.map +1 -1
  54. package/dist/client/react/boardRouter.d.ts +3 -2
  55. package/dist/client/react/boardRouter.d.ts.map +1 -1
  56. package/dist/client/react/boardRouter.js +2 -1
  57. package/dist/client/react/boardRouter.js.map +1 -1
  58. package/dist/client/react/createResponse.d.ts +32 -4
  59. package/dist/client/react/createResponse.d.ts.map +1 -1
  60. package/dist/client/react/createResponse.js +7 -28
  61. package/dist/client/react/createResponse.js.map +1 -1
  62. package/dist/client/react/diagnosticsValues.d.ts.map +1 -1
  63. package/dist/client/react/diagnosticsValues.js +5 -7
  64. package/dist/client/react/diagnosticsValues.js.map +1 -1
  65. package/dist/client/react/entry.d.ts.map +1 -1
  66. package/dist/client/react/entry.js +2 -34
  67. package/dist/client/react/entry.js.map +1 -1
  68. package/dist/client/react/githubLimited.d.ts +3 -0
  69. package/dist/client/react/githubLimited.d.ts.map +1 -0
  70. package/dist/client/react/githubLimited.js +4 -0
  71. package/dist/client/react/githubLimited.js.map +1 -0
  72. package/dist/client/react/loadProjection.d.ts +135 -135
  73. package/dist/client/react/loadRemaining.d.ts +37 -29
  74. package/dist/client/react/loadRemaining.d.ts.map +1 -1
  75. package/dist/client/react/mainLocation.d.ts +4 -2
  76. package/dist/client/react/mainLocation.d.ts.map +1 -1
  77. package/dist/client/react/mainLocation.js +1 -8
  78. package/dist/client/react/mainLocation.js.map +1 -1
  79. package/dist/client/react/mountBoard.d.ts +3 -0
  80. package/dist/client/react/mountBoard.d.ts.map +1 -0
  81. package/dist/client/react/mountBoard.js +45 -0
  82. package/dist/client/react/mountBoard.js.map +1 -0
  83. package/dist/client/react/navigationLocation.d.ts +1 -1
  84. package/dist/client/react/navigationLocation.d.ts.map +1 -1
  85. package/dist/client/react/navigationLocation.js +1 -1
  86. package/dist/client/react/navigationLocation.js.map +1 -1
  87. package/dist/client/react/retryPage.d.ts +2 -1
  88. package/dist/client/react/retryPage.d.ts.map +1 -1
  89. package/dist/client/react/retryPage.js +3 -4
  90. package/dist/client/react/retryPage.js.map +1 -1
  91. package/dist/client/react/useBoardNavigation.d.ts.map +1 -1
  92. package/dist/client/react/useBoardNavigation.js +18 -8
  93. package/dist/client/react/useBoardNavigation.js.map +1 -1
  94. package/dist/client/react/useDocumentPage.d.ts +1 -1
  95. package/dist/client/react/useDocumentPage.d.ts.map +1 -1
  96. package/dist/client/react/useDocumentPage.js +9 -6
  97. package/dist/client/react/useDocumentPage.js.map +1 -1
  98. package/dist/client/react/useHandoffForm.d.ts +56 -30
  99. package/dist/client/react/useHandoffForm.d.ts.map +1 -1
  100. package/dist/client/react/useHandoffForm.js +19 -30
  101. package/dist/client/react/useHandoffForm.js.map +1 -1
  102. package/dist/client/react/useResponseForm.d.ts +72 -43
  103. package/dist/client/react/useResponseForm.d.ts.map +1 -1
  104. package/dist/client/react/useResponseForm.js +20 -17
  105. package/dist/client/react/useResponseForm.js.map +1 -1
  106. package/dist/client/react/visualSources.d.ts.map +1 -1
  107. package/dist/client/react/visualSources.js +10 -8
  108. package/dist/client/react/visualSources.js.map +1 -1
  109. package/dist/client/saveFailure.d.ts +8 -0
  110. package/dist/client/saveFailure.d.ts.map +1 -0
  111. package/dist/client/saveFailure.js +8 -0
  112. package/dist/client/saveFailure.js.map +1 -0
  113. package/dist/diagnostics/daemonContract.d.ts +65 -38
  114. package/dist/diagnostics/daemonContract.d.ts.map +1 -1
  115. package/dist/diagnostics/daemonContract.js +2 -1
  116. package/dist/diagnostics/daemonContract.js.map +1 -1
  117. package/dist/diagnostics/health.d.ts +10 -0
  118. package/dist/diagnostics/health.d.ts.map +1 -1
  119. package/dist/diagnostics/health.js +2 -0
  120. package/dist/diagnostics/health.js.map +1 -1
  121. package/dist/diagnostics/source.d.ts +55 -36
  122. package/dist/diagnostics/source.d.ts.map +1 -1
  123. package/dist/diagnostics/source.js +1 -2
  124. package/dist/diagnostics/source.js.map +1 -1
  125. package/dist/rpc/contract.d.ts +60 -36
  126. package/dist/rpc/contract.d.ts.map +1 -1
  127. package/dist/server/diagnostics/daemon.d.ts +41 -25
  128. package/dist/server/diagnostics/daemon.d.ts.map +1 -1
  129. package/dist/server/diagnostics/fleetHealth.d.ts +10 -0
  130. package/dist/server/diagnostics/fleetHealth.d.ts.map +1 -1
  131. package/dist/server/diagnostics/githubLimits.d.ts +12 -13
  132. package/dist/server/diagnostics/githubLimits.d.ts.map +1 -1
  133. package/dist/server/diagnostics/githubLimits.js +6 -6
  134. package/dist/server/diagnostics/githubLimits.js.map +1 -1
  135. package/dist/server/integration/diagnostics.d.ts +74 -38
  136. package/dist/server/integration/diagnostics.d.ts.map +1 -1
  137. package/dist/server/mcp/client.d.ts +20 -12
  138. package/dist/server/mcp/client.d.ts.map +1 -1
  139. package/dist/server/mcp/queries.d.ts +60 -36
  140. package/dist/server/mcp/queries.d.ts.map +1 -1
  141. package/dist/server/metadata/fields.js +1 -1
  142. package/dist/server/metadata/fields.js.map +1 -1
  143. package/dist/server/packageInfo.d.ts +1 -1
  144. package/dist/server/packageInfo.js +1 -1
  145. package/dist/server/views/remainingQueries.d.ts +20 -12
  146. package/dist/server/views/remainingQueries.d.ts.map +1 -1
  147. package/package.json +7 -6
  148. package/src/browser/diagnostics/schema.ts +12 -2
  149. package/src/client/client.ts +2 -1
  150. package/src/client/formStatus.ts +35 -0
  151. package/src/client/react/BoardShell.tsx +19 -26
  152. package/src/client/react/DocumentContext.tsx +6 -3
  153. package/src/client/react/FormContext.ts +13 -0
  154. package/src/client/react/LiveControls.tsx +5 -25
  155. package/src/client/react/MainLocationContext.ts +8 -0
  156. package/src/client/react/ProjectionFrame.tsx +13 -5
  157. package/src/client/react/ReadingLibrary.tsx +7 -8
  158. package/src/client/react/SystemPage.tsx +2 -1
  159. package/src/client/react/TodayOptions.tsx +40 -33
  160. package/src/client/react/VisualViewer.tsx +6 -6
  161. package/src/client/react/boardRouter.ts +3 -2
  162. package/src/client/react/createResponse.ts +10 -35
  163. package/src/client/react/diagnosticsValues.tsx +20 -12
  164. package/src/client/react/entry.tsx +2 -37
  165. package/src/client/react/githubLimited.ts +5 -0
  166. package/src/client/react/mainLocation.ts +5 -8
  167. package/src/client/react/mountBoard.tsx +49 -0
  168. package/src/client/react/navigationLocation.ts +2 -2
  169. package/src/client/react/retryPage.ts +4 -4
  170. package/src/client/react/useBoardNavigation.ts +20 -8
  171. package/src/client/react/useDocumentPage.ts +12 -6
  172. package/src/client/react/useHandoffForm.ts +25 -33
  173. package/src/client/react/useResponseForm.ts +20 -18
  174. package/src/client/react/visualSources.ts +10 -8
  175. package/src/client/saveFailure.ts +14 -0
  176. package/src/diagnostics/daemonContract.ts +2 -1
  177. package/src/diagnostics/health.ts +2 -0
  178. package/src/diagnostics/source.ts +1 -2
  179. package/src/server/diagnostics/githubLimits.ts +9 -11
  180. package/src/server/metadata/fields.ts +1 -1
  181. package/src/server/packageInfo.ts +1 -1
  182. package/dist/client/handoffs/failureStatus.d.ts +0 -2
  183. package/dist/client/handoffs/failureStatus.d.ts.map +0 -1
  184. package/dist/client/handoffs/failureStatus.js +0 -7
  185. package/dist/client/handoffs/failureStatus.js.map +0 -1
  186. package/dist/client/responses/failure.d.ts +0 -5
  187. package/dist/client/responses/failure.d.ts.map +0 -1
  188. package/dist/client/responses/failure.js +0 -13
  189. package/dist/client/responses/failure.js.map +0 -1
  190. package/dist/server/diagnostics/readCommand.d.ts +0 -4
  191. package/dist/server/diagnostics/readCommand.d.ts.map +0 -1
  192. package/dist/server/diagnostics/readCommand.js +0 -20
  193. package/dist/server/diagnostics/readCommand.js.map +0 -1
  194. package/src/client/handoffs/failureStatus.ts +0 -6
  195. package/src/client/responses/failure.ts +0 -13
  196. package/src/server/diagnostics/readCommand.ts +0 -24
@@ -1,26 +1,21 @@
1
1
  import { useAtomValue } from "@effect/atom-react";
2
2
  import { Option } from "effect";
3
3
  import * as Atom from "effect/reactivity/Atom";
4
- import { useEffect, useMemo, useState } from "react";
4
+ import { useEffect, useMemo } from "react";
5
5
  import { useQuery } from "@shivaedev/effect-react/result-state.ts";
6
6
  import type { BrowserClient } from "#client/client.ts";
7
7
  import { activeRead } from "./activeRead.ts";
8
8
  import { liveStatus } from "./liveStatus.ts";
9
+ import { useMainLocation } from "./MainLocationContext.ts";
9
10
  import { shellIds } from "./shellIds.ts";
10
11
 
11
12
  const opening = Atom.make({ failed: false, pending: true });
12
- interface Page {
13
- readonly kind: string;
14
- readonly url: string;
15
- }
16
13
  export function LiveControls({
17
14
  client,
18
- current,
19
15
  navigationPending,
20
16
  navigationFailed,
21
17
  }: {
22
- readonly client: BrowserClient;
23
- readonly current: string;
18
+ readonly client: Pick<BrowserClient, "api" | "registry"> & { readonly updates: Pick<BrowserClient["updates"], "paused" | "status"> };
24
19
  readonly navigationPending: boolean;
25
20
  readonly navigationFailed: boolean;
26
21
  }) {
@@ -28,23 +23,8 @@ export function LiveControls({
28
23
  const paused = useAtomValue(client.updates.paused);
29
24
  const watcher = useQuery(client.api.watcher.query());
30
25
  const watching = Option.getOrUndefined(watcher.data)?.watching === true;
31
- const [page, setPage] = useState<Page>();
32
- useEffect(() => {
33
- function sync() {
34
- const doc = document.getElementById("doc");
35
- if (doc?.dataset.route !== current) {
36
- return;
37
- }
38
- setPage({
39
- kind: doc?.dataset.projection ?? "document",
40
- url: current,
41
- });
42
- }
43
- sync();
44
- document.addEventListener("board-page", sync);
45
- return () => document.removeEventListener("board-page", sync);
46
- }, [current]);
47
- const atom = useMemo(() => (page ? activeRead(client.api, page.kind, page.url) : opening), [client, page]);
26
+ const page = useMainLocation();
27
+ const atom = useMemo(() => (page.route ? activeRead(client.api, page.projection, page.route) : opening), [client, page.projection, page.route]);
48
28
  const reading = useAtomValue(atom);
49
29
  const displayed = liveStatus({
50
30
  connection: status.connection,
@@ -0,0 +1,8 @@
1
+ import { createContext, useContext } from "react";
2
+ import { emptyMainLocation } from "./mainLocation.ts";
3
+
4
+ export const MainLocationContext = createContext(emptyMainLocation);
5
+
6
+ export function useMainLocation() {
7
+ return useContext(MainLocationContext);
8
+ }
@@ -21,7 +21,7 @@ export function ProjectionFrame<A, E>({
21
21
  url,
22
22
  }: {
23
23
  readonly atom: Atom.Atom<AsyncResult.AsyncResult<A, E>>;
24
- readonly location: (data: A) => MainLocation & { readonly identity?: string | undefined; readonly status: number };
24
+ readonly location: (data: A) => Omit<MainLocation, "route" | "projection"> & { readonly identity?: string | undefined };
25
25
  readonly onPage: (location: MainLocation) => void;
26
26
  readonly render: (data: A) => ReactNode;
27
27
  readonly source?: (data: A) => string | undefined;
@@ -36,7 +36,7 @@ export function ProjectionFrame<A, E>({
36
36
  const failed = Option.isSome(query.cause);
37
37
  useLayoutEffect(() => {
38
38
  const element = hostRef.current;
39
- if (element && element.dataset.route !== url) {
39
+ if (element) {
40
40
  delete element.dataset.route;
41
41
  }
42
42
  if (!element || query.pending || (data === undefined && !failed)) {
@@ -56,8 +56,16 @@ export function ProjectionFrame<A, E>({
56
56
  }
57
57
  renderedRef.current = projectionChanges(mainElement, renderedRef.current, url);
58
58
  mainElement.dataset.route = url;
59
- flushSync(() => onPage({ file: main.file, title: mainElement.querySelector("h1")?.textContent?.trim(), view: main.view }));
60
- document.dispatchEvent(new Event("board-page"));
59
+ flushSync(() =>
60
+ onPage({
61
+ file: main.file,
62
+ projection: route,
63
+ route: url,
64
+ status: main.status,
65
+ title: mainElement.querySelector("h1")?.textContent?.trim(),
66
+ view: main.view,
67
+ }),
68
+ );
61
69
  });
62
70
  }
63
71
  const observer = new MutationObserver((records) => {
@@ -78,7 +86,7 @@ export function ProjectionFrame<A, E>({
78
86
  active = false;
79
87
  observer.disconnect();
80
88
  };
81
- }, [data, failed, query.pending, expectedSource, main.file, main.view, onPage, url]);
89
+ }, [data, failed, query.pending, expectedSource, main.file, main.status, main.view, onPage, route, url]);
82
90
  return (
83
91
  <>
84
92
  <main
@@ -1,5 +1,6 @@
1
1
  import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
2
2
  import { LibraryContext, restoreLibrary } from "#client/react/LibraryContext.ts";
3
+ import { useMainLocation } from "./MainLocationContext.ts";
3
4
  import { Modal } from "./Modal.tsx";
4
5
  import { ReadingHistory } from "./ReadingHistory.tsx";
5
6
  import type { ShellApi } from "./ShellApi.ts";
@@ -16,6 +17,7 @@ export function ReadingLibrary({
16
17
  readonly close: () => void;
17
18
  readonly children: ReactNode;
18
19
  }) {
20
+ const page = useMainLocation();
19
21
  const key = `work-board:library:${document.documentElement.dataset.workspace}`;
20
22
  const [saved, setSaved] = useState(() => restoreLibrary(key));
21
23
  const [current, setCurrent] = useState("");
@@ -36,16 +38,15 @@ export function ReadingLibrary({
36
38
  );
37
39
  useEffect(() => {
38
40
  function sync() {
39
- const doc = document.getElementById("doc");
40
- if (!doc?.dataset.route) {
41
+ if (!page.route) {
41
42
  return;
42
43
  }
43
- const file = doc.dataset.file ?? "";
44
- const title = doc.querySelector("h1")?.textContent?.trim() || file;
44
+ const file = page.file;
45
+ const title = page.title || file;
45
46
  document.title = title ? `${title} · Work Board` : "Work Board";
46
47
 
47
48
  setCurrent(file);
48
- if (file && doc.querySelector("#favorite-toggle") && visitedRef.current !== `${file}:${title}`) {
49
+ if (file && page.view === "" && visitedRef.current !== `${file}:${title}`) {
49
50
  visitedRef.current = `${file}:${title}`;
50
51
  save({
51
52
  favorites: savedRef.current.favorites,
@@ -54,13 +55,11 @@ export function ReadingLibrary({
54
55
  }
55
56
  }
56
57
  sync();
57
- document.addEventListener("board-page", sync);
58
58
  document.addEventListener("board-index-change", sync);
59
59
  return () => {
60
- document.removeEventListener("board-page", sync);
61
60
  document.removeEventListener("board-index-change", sync);
62
61
  };
63
- }, [save]);
62
+ }, [save, page]);
64
63
 
65
64
  return (
66
65
  <LibraryContext.Provider
@@ -4,6 +4,7 @@ import type { DiagnosticsProjection } from "#browser/views/remaining.ts";
4
4
  import { Card as Surface } from "#client/react/components/ui/Card.tsx";
5
5
  import { DiagnosticsGithub, DiagnosticsWindows, ObservedTime } from "./diagnosticsValues.tsx";
6
6
  import { FleetItemsContext } from "./FleetItemsContext.ts";
7
+ import { githubLimited } from "./githubLimited.ts";
7
8
  import { SystemFleetLane } from "./SystemFleetLane.tsx";
8
9
  import { SystemHealth } from "./SystemHealth.tsx";
9
10
  export function SystemPage({ data: { sources: data, now } }: { readonly data: DiagnosticsProjection }) {
@@ -24,7 +25,7 @@ export function SystemPage({ data: { sources: data, now } }: { readonly data: Di
24
25
  source={data.codex}
25
26
  />
26
27
  <Card
27
- attention={data.github.state === "ready" && [data.github.value.core, data.github.value.graphql].some((window) => window.remaining === 0)}
28
+ attention={data.github.state === "ready" && data.github.value.some((window) => githubLimited(window, now))}
28
29
  name="GitHub"
29
30
  render={(value) => <DiagnosticsGithub now={now} value={value} />}
30
31
  source={data.github}
@@ -1,6 +1,5 @@
1
1
  import { RegistryContext } from "@effect/atom-react";
2
- import { Effect, Option } from "effect";
3
- import * as Atom from "effect/reactivity/Atom";
2
+ import { Cause, Effect, Option } from "effect";
4
3
  import { useContext, useMemo, useState } from "react";
5
4
  import { useAction, useQuery } from "@shivaedev/effect-react/result-state.ts";
6
5
  import type { Document, DocumentRequest } from "#browser/document/schema.ts";
@@ -11,8 +10,10 @@ import { documentQuery, navigationQuery, routeQueryOptions } from "#client/page/
11
10
  import { Button } from "#client/react/components/ui/Button.tsx";
12
11
  import { Popover } from "#client/react/components/ui/Popover.tsx";
13
12
  import { authorLabel } from "#client/responses/authorLabel.ts";
13
+ import { saveFailure } from "#client/saveFailure.ts";
14
14
  import { fileUrl } from "#files/url.ts";
15
15
  import { createResponse } from "./createResponse.ts";
16
+ import { useFormClient } from "./FormContext.ts";
16
17
  import type { ShellApi } from "./ShellApi.ts";
17
18
 
18
19
  export function TodayOptions({ api, item }: { readonly api: ShellApi; readonly item: InboxProjection["you"][number] }) {
@@ -42,42 +43,48 @@ export function TodayOptions({ api, item }: { readonly api: ShellApi; readonly i
42
43
 
43
44
  function InlineOptions({ api, document: data, request }: { readonly api: ShellApi; readonly document: Document; readonly request: DocumentRequest }) {
44
45
  const registry = useContext(RegistryContext);
45
- const [status, setStatus] = useState("");
46
- const [outcome, setOutcome] = useState("");
46
+ const client = useFormClient();
47
47
  const [label] = useState(() => authorLabel(() => localStorage, document.documentElement.dataset.workspace));
48
48
  const [author, setAuthor] = useState(() => label.read("") || "maintainer");
49
49
  const actionAtom = useMemo(
50
50
  () =>
51
- Atom.fn<string>()((choice) => {
52
- const preview = request.preview;
53
- const prompt = request.prompts?.[0];
54
- if (!(preview && prompt && author.trim())) {
55
- return Effect.fail("Add your local author label.");
56
- }
57
- const answers = [{ prompt: prompt.id, selected: [choice], text: "" }];
58
- const submission = {
59
- answers,
60
- author: author.trim(),
61
- body: answerBody([prompt], answers, ""),
62
- id: `response.${crypto.randomUUID()}`,
63
- question: preview.id,
64
- revision: preview.reviewedRevision,
65
- type: "answer" as const,
66
- };
67
- return createResponse(preview, submission, () => submission, setOutcome, setStatus).pipe(
68
- Effect.tap(() =>
69
- Effect.sync(() => {
70
- label.save(author);
71
- registry.refresh(api.inbox.query(undefined, routeQueryOptions));
72
- registry.refresh(navigationQuery(api));
73
- registry.refresh(documentQuery(api, fileUrl(data.file)));
74
- }),
75
- ),
76
- );
77
- }),
78
- [api, registry, request, author, data.file, label.save],
51
+ client.runtime.fn(
52
+ Effect.fn("Board.answerOption")(function* (choice: string) {
53
+ const preview = request.preview;
54
+ const prompt = request.prompts?.[0];
55
+ if (!(preview && prompt && author.trim())) {
56
+ return yield* Effect.fail("Add your local author label.");
57
+ }
58
+ const answers = [{ prompt: prompt.id, selected: [choice], text: "" }];
59
+ const submission = {
60
+ answers,
61
+ author: author.trim(),
62
+ body: answerBody([prompt], answers, ""),
63
+ id: `response.${crypto.randomUUID()}`,
64
+ question: preview.id,
65
+ revision: preview.reviewedRevision,
66
+ type: "answer" as const,
67
+ };
68
+ const saved = yield* createResponse(client, preview, submission);
69
+ label.save(author);
70
+ registry.refresh(api.inbox.query(undefined, routeQueryOptions));
71
+ registry.refresh(navigationQuery(api));
72
+ registry.refresh(documentQuery(api, fileUrl(data.file)));
73
+ return saved;
74
+ }),
75
+ ),
76
+ [client, api, registry, request, author, data.file, label.save],
79
77
  );
80
78
  const action = useAction(actionAtom);
79
+ const failure = Option.getOrUndefined(Option.flatMap(action.cause, Cause.findErrorOption));
80
+ const saved = Option.getOrUndefined(action.data);
81
+ let status = saved ? `Saved: responses/${saved.id}.md. This records feedback, not acceptance.` : "";
82
+ if (failure) {
83
+ status = typeof failure === "string" ? failure : saveFailure(failure, "response").text;
84
+ }
85
+ if (action.pending) {
86
+ status = "Pending: registering reviewed context, then recording response…";
87
+ }
81
88
  const prompt = request.prompts?.[0];
82
89
  return (
83
90
  <fieldset aria-label={`Options for ${data.identity}`} className="m-0 min-w-0 space-y-2 border-0 px-2 pb-2">
@@ -86,7 +93,7 @@ function InlineOptions({ api, document: data, request }: { readonly api: ShellAp
86
93
  const text = data.metadata.options?.[index]?.label ?? optionLabel(option.id, option.source, request.preview?.reason ?? "");
87
94
  return (
88
95
  <Button
89
- disabled={!(data.writable && author.trim()) || action.pending || outcome === "pending" || outcome === "saved"}
96
+ disabled={!(data.writable && author.trim()) || action.pending || Boolean(saved)}
90
97
  key={option.id}
91
98
  onClick={() => action.dispatch(option.id)}
92
99
  size="sm"
@@ -1,11 +1,14 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
2
  import { KeyboardHints } from "./KeyboardHints.tsx";
3
+ import { useMainLocation } from "./MainLocationContext.ts";
3
4
  import { shellIds } from "./shellIds.ts";
4
5
  import { VisualControls } from "./VisualControls.tsx";
5
6
  import { graphicWidth, visualFilename } from "./visualDimensions.ts";
6
7
  import type { VisualSelection } from "./visualSources.ts";
7
8
 
8
9
  export function VisualViewer({ selection, close }: { readonly selection: VisualSelection; readonly close: () => void }) {
10
+ const page = useMainLocation();
11
+ const openedPageRef = useRef(page);
9
12
  const [selected, setSelected] = useState(selection.selected);
10
13
  const [scale, setScale] = useState(100);
11
14
  const [baseWidth, setBaseWidth] = useState(0);
@@ -27,13 +30,11 @@ export function VisualViewer({ selection, close }: { readonly selection: VisualS
27
30
  return () => URL.revokeObjectURL(url);
28
31
  }, [selection.drawing]);
29
32
  useEffect(() => {
30
- function changed() {
33
+ if (page !== openedPageRef.current) {
31
34
  setStale(true);
32
35
  setStatus("Source updated. This preview is the drawing/image opened earlier; close and reopen to inspect or save the latest source.");
33
36
  }
34
- document.addEventListener("board-page", changed);
35
- return () => document.removeEventListener("board-page", changed);
36
- }, []);
37
+ }, [page]);
37
38
  function handleFitVisual() {
38
39
  setBaseWidth(0);
39
40
  setScale(100);
@@ -114,8 +115,7 @@ export function VisualViewer({ selection, close }: { readonly selection: VisualS
114
115
  </button>
115
116
  </div>
116
117
  <p id={shellIds.visualContext}>
117
- {image ? `${image.alt || "Local image"} · ${selected + 1} of ${selection.images.length} · ${image.local}` : "Mermaid diagram"} ·{" "}
118
- {document.getElementById("doc")?.dataset.file}
118
+ {image ? `${image.alt || "Local image"} · ${selected + 1} of ${selection.images.length} · ${image.local}` : "Mermaid diagram"} · {page.file}
119
119
  {image ? "" : " · Current drawing"}
120
120
  </p>
121
121
  <VisualControls
@@ -1,8 +1,8 @@
1
- import { createRootRouteWithContext, createRoute, createRouter } from "@tanstack/react-router";
1
+ import { createRootRouteWithContext, createRoute, createRouter, type RouterHistory } from "@tanstack/react-router";
2
2
  import { createElement } from "react";
3
3
  import { BoardShell } from "./BoardShell.tsx";
4
4
  import { loadBoardPage, type RouterShell } from "./loadBoardPage.ts";
5
- export function boardRouter(options?: RouterShell) {
5
+ export function boardRouter(options?: RouterShell, history?: RouterHistory) {
6
6
  const root = createRootRouteWithContext<{ readonly shell?: RouterShell }>()({
7
7
  component: () => {
8
8
  const { shell } = root.useRouteContext();
@@ -24,6 +24,7 @@ export function boardRouter(options?: RouterShell) {
24
24
  path: "$",
25
25
  });
26
26
  return createRouter({
27
+ ...(history ? { history } : {}),
27
28
  context: options ? { shell: options } : {},
28
29
  defaultPendingMs: Number.POSITIVE_INFINITY,
29
30
  defaultPreloadStaleTime: 0,
@@ -1,40 +1,15 @@
1
- import { Effect, Equal } from "effect";
1
+ import { Effect } from "effect";
2
2
  import type { QuestionPreview } from "#browser/responses/schema.ts";
3
- import { session } from "#client/entry.ts";
4
- import { responseFailure } from "#client/responses/failure.ts";
5
- import type { ResponseInput, ResponseSubmission } from "#client/responses/formSchema.ts";
3
+ import type { ResponseSubmission } from "#client/responses/formSchema.ts";
4
+ import type { FormClient } from "./FormContext.ts";
6
5
 
7
- export function createResponse(
6
+ export const createResponse = Effect.fn("Board.createResponse")(function* (
7
+ client: FormClient,
8
8
  source: QuestionPreview,
9
9
  submission: ResponseSubmission,
10
- current: () => ResponseInput | undefined,
11
- setOutcome: (outcome: string) => void,
12
- setStatus: (status: string) => void,
13
10
  ) {
14
- return Effect.tryPromise({
15
- catch: (error) => error,
16
- try: async () => {
17
- const native = session(window);
18
- setOutcome("pending");
19
- setStatus("Pending: registering reviewed context, then recording response…");
20
- try {
21
- const question = await native.mutate(
22
- native.responses.registerQuestion.run({ item: source.item, request: source.request, revision: submission.revision }),
23
- );
24
- const { answerContext: _answerContext, recovery: _recovery, ...input } = submission;
25
- await native.mutate(native.responses.recordResponse.run({ ...input, question: question.id }));
26
- const newer = !Equal.equals(current(), submission);
27
- setOutcome("saved");
28
- setStatus(
29
- `Saved: responses/${submission.id}.md. This records feedback, not acceptance.${newer ? " Your current draft remains unsaved." : ""}`,
30
- );
31
- return submission;
32
- } catch (error) {
33
- const failure = responseFailure(error);
34
- setOutcome(failure.outcome);
35
- setStatus(failure.text);
36
- throw error;
37
- }
38
- },
39
- });
40
- }
11
+ const question = yield* client.responses.registerQuestion.run({ item: source.item, request: source.request, revision: submission.revision });
12
+ const { answerContext: _answerContext, recovery: _recovery, ...input } = submission;
13
+ yield* client.responses.recordResponse.run({ ...input, question: question.id });
14
+ return submission;
15
+ });
@@ -1,4 +1,4 @@
1
- import type { CodexLimits, GithubLimits, UsageWindow } from "#browser/diagnostics/schema.ts";
1
+ import type { CodexLimits, GithubLimits, GithubWindow, UsageWindow } from "#browser/diagnostics/schema.ts";
2
2
  import { resetLabel } from "#diagnostics/resetLabel.ts";
3
3
  export function ObservedTime({ at }: { readonly at: number | null }) {
4
4
  return at === null ? (
@@ -71,33 +71,41 @@ export function DiagnosticsWindows({ value, now }: { readonly value: CodexLimits
71
71
  </ul>
72
72
  );
73
73
  }
74
+ const GITHUB_RESOURCES: Readonly<Record<string, string>> = { core: "REST", graphql: "GraphQL", search: "Search" };
75
+ const GITHUB_PAUSES = { "rate-limit": "Rate-limited", reserve: "GitHub paused below the 20% reserve" } as const;
74
76
  export function DiagnosticsGithub({ value, now }: { readonly value: GithubLimits; readonly now: number }) {
75
- const windows = (
76
- [
77
- ["REST", value.core],
78
- ["GraphQL", value.graphql],
79
- ] as const
80
- ).map(([name, window]) => <GithubWindow key={name} name={name} now={now} window={window} />);
81
77
  return (
82
78
  <>
83
- <dl className="grid gap-4 text-sm [&_dt]:text-muted-foreground">{windows}</dl>
84
- <p className="text-muted-foreground text-xs">Cached for one minute across all tabs.</p>
79
+ <dl className="grid gap-4 text-sm [&_dt]:text-muted-foreground">
80
+ {value.map((window) => (
81
+ <GithubResource key={window.resource} now={now} window={window} />
82
+ ))}
83
+ </dl>
84
+ <p className="text-muted-foreground text-xs">Recorded from the rate-limit headers of Fleet's own GitHub responses.</p>
85
85
  </>
86
86
  );
87
87
  }
88
- function GithubWindow({ name, window, now }: { readonly name: string; readonly window: GithubLimits["core"]; readonly now: number }) {
88
+ function GithubResource({ window, now }: { readonly window: GithubWindow; readonly now: number }) {
89
89
  return (
90
- <div>
91
- <dt>{name}</dt>
90
+ <div data-resource={window.resource}>
91
+ <dt>{GITHUB_RESOURCES[window.resource] ?? window.resource}</dt>
92
92
  <dd>
93
93
  <strong>
94
94
  {window.remaining} / {window.limit}
95
95
  </strong>{" "}
96
96
  remaining
97
97
  </dd>
98
+ {window.limitedUntil !== null && window.limitedUntil > now ? (
99
+ <dd>
100
+ <strong>{GITHUB_PAUSES[window.limitedBy ?? "rate-limit"]}</strong> until <Reset at={window.limitedUntil} now={now} />
101
+ </dd>
102
+ ) : null}
98
103
  <dd>
99
104
  Resets <Reset at={window.reset * 1000} now={now} />
100
105
  </dd>
106
+ <dd>
107
+ Observed <time dateTime={new Date(window.observedAt).toISOString()}>{resetLabel(window.observedAt, now)}</time>
108
+ </dd>
101
109
  </div>
102
110
  );
103
111
  }
@@ -1,43 +1,8 @@
1
- import { RegistryContext } from "@effect/atom-react";
2
- import { RouterProvider } from "@tanstack/react-router";
3
- import { createRoot } from "react-dom/client";
4
1
  import { session } from "#client/entry.ts";
5
- import { boardRouter } from "./boardRouter.ts";
2
+ import { mountBoard } from "./mountBoard.tsx";
6
3
  import "#client/react/entry.css";
7
4
 
8
5
  const container = document.querySelector("[data-board-react-root]");
9
6
  if (container) {
10
- const root = createRoot(container);
11
- let native = session(window);
12
- let started = false;
13
- function ready() {
14
- if (started) {
15
- return;
16
- }
17
- started = true;
18
- document.documentElement.dataset.boardReady = "true";
19
- document.documentElement.dataset.js = "true";
20
- }
21
- const reading = history.state?.workBoard;
22
- const router = boardRouter({ api: native.api, client: native, reading, ready, registry: native.registry });
23
- if (reading) {
24
- history.replaceState({ ...history.state, workBoard: reading }, "");
25
- }
26
- function render() {
27
- root.render(
28
- <RegistryContext.Provider value={native.registry}>
29
- <RouterProvider router={router} />
30
- </RegistryContext.Provider>,
31
- );
32
- }
33
- render();
34
- window.addEventListener("pageshow", (event) => {
35
- if (!event.persisted) {
36
- return;
37
- }
38
- native = session(window);
39
- router.update({ context: { shell: { api: native.api, client: native, reading, ready, registry: native.registry } } });
40
- void router.invalidate();
41
- render();
42
- });
7
+ mountBoard(container, () => session(window));
43
8
  }
@@ -0,0 +1,5 @@
1
+ import type { GithubWindow } from "#browser/diagnostics/schema.ts";
2
+
3
+ export function githubLimited(window: GithubWindow, now: number) {
4
+ return window.remaining === 0 || (window.limitedUntil !== null && window.limitedUntil > now);
5
+ }
@@ -1,13 +1,10 @@
1
1
  export interface MainLocation {
2
2
  readonly file: string;
3
+ readonly projection: string;
4
+ readonly route: string;
5
+ readonly status: number;
3
6
  readonly title?: string | undefined;
4
7
  readonly view: string;
5
8
  }
6
- export function displayedLocation(main: HTMLElement | null): MainLocation {
7
- const file = main?.dataset.file ?? "";
8
- const title = main?.querySelector("h1")?.textContent?.trim();
9
- return { file, title, view: main?.dataset.view ?? "" };
10
- }
11
- export function sameMainLocation(previous: MainLocation, next: MainLocation) {
12
- return previous.file === next.file && previous.view === next.view && previous.title === next.title;
13
- }
9
+
10
+ export const emptyMainLocation: MainLocation = { file: "", projection: "document", route: "", status: 200, view: "" };
@@ -0,0 +1,49 @@
1
+ import { RegistryContext } from "@effect/atom-react";
2
+ import { RouterProvider } from "@tanstack/react-router";
3
+ import { createRoot } from "react-dom/client";
4
+ import type { BrowserClient } from "#client/client.ts";
5
+ import { boardRouter } from "./boardRouter.ts";
6
+
7
+ export function mountBoard(container: Element, readSession: () => BrowserClient) {
8
+ const root = createRoot(container);
9
+ let native = readSession();
10
+ let generation = 0;
11
+ let started = false;
12
+ function ready() {
13
+ if (started) {
14
+ return;
15
+ }
16
+ started = true;
17
+ document.documentElement.dataset.boardReady = "true";
18
+ document.documentElement.dataset.js = "true";
19
+ }
20
+ const reading = history.state?.workBoard;
21
+ let router = boardRouter({ api: native.api, client: native, reading, ready, registry: native.registry });
22
+ const navigation = router.history;
23
+ if (reading) {
24
+ history.replaceState({ ...history.state, workBoard: reading }, "");
25
+ }
26
+ function render() {
27
+ root.render(
28
+ <RegistryContext.Provider key={generation} value={native.registry}>
29
+ <RouterProvider router={router} />
30
+ </RegistryContext.Provider>,
31
+ );
32
+ }
33
+ render();
34
+ function restore(event: PageTransitionEvent) {
35
+ if (!event.persisted) {
36
+ return;
37
+ }
38
+ native = readSession();
39
+ generation += 1;
40
+ router = boardRouter({ api: native.api, client: native, reading, ready, registry: native.registry }, navigation);
41
+ render();
42
+ }
43
+ window.addEventListener("pageshow", restore);
44
+ return () => {
45
+ window.removeEventListener("pageshow", restore);
46
+ root.unmount();
47
+ navigation.destroy();
48
+ };
49
+ }
@@ -1,7 +1,7 @@
1
1
  import { readingEntry } from "#client/react/storedReadings.ts";
2
2
 
3
- export function navigationFailure(status: string | undefined) {
4
- const code = Number(status ?? 200);
3
+ export function navigationFailure(status: number) {
4
+ const code = status;
5
5
  if (code < 400) {
6
6
  return "";
7
7
  }
@@ -1,11 +1,11 @@
1
1
  import type * as AtomRegistry from "effect/reactivity/AtomRegistry";
2
+ import type { MainLocation } from "./mainLocation.ts";
2
3
  import { pageQueries } from "./pageQueries.ts";
3
4
  import type { ShellApi } from "./ShellApi.ts";
4
5
 
5
- export function retryPage(api: ShellApi, registry: AtomRegistry.AtomRegistry, url: string) {
6
- const doc = document.getElementById("doc");
7
- const queries = pageQueries(api, doc?.dataset.projection ?? "document", url);
8
- if (Number(doc?.dataset.status ?? 200) < 400 && !queries.some((query) => registry.get(query)._tag === "Failure")) {
6
+ export function retryPage(api: ShellApi, registry: AtomRegistry.AtomRegistry, page: MainLocation) {
7
+ const queries = pageQueries(api, page.projection, page.route);
8
+ if (page.status < 400 && !queries.some((query) => registry.get(query)._tag === "Failure")) {
9
9
  return false;
10
10
  }
11
11
  for (const query of queries) {