@scoutmesh/viewer 0.2.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 +534 -0
- package/dist/cli.js +430 -0
- package/dist/external-browser.js +62 -0
- package/dist/input-error.js +8 -0
- package/dist/launcher.js +39 -0
- package/dist/public/app.js +1157 -0
- package/dist/public/auth.js +55 -0
- package/dist/public/data.js +105 -0
- package/dist/public/index.html +153 -0
- package/dist/public/styles.css +619 -0
- package/dist/server.js +339 -0
- package/dist/state-file.js +48 -0
- package/dist/store.js +437 -0
- package/dist/update-manager.js +448 -0
- package/dist/version.js +1 -0
- package/package.json +20 -0
- package/skills/local-candidate-viewer/SKILL.md +200 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { isObject } from "./data.js";
|
|
2
|
+
|
|
3
|
+
/** Unlock the page without putting credentials in its URL or browser storage. */
|
|
4
|
+
export async function unlockViewer() {
|
|
5
|
+
const response = await fetch("/api/session");
|
|
6
|
+
/** @type {unknown} */
|
|
7
|
+
const session = await response.json();
|
|
8
|
+
if (isObject(session) && session.authorized) {
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
const panel = document.querySelector("#unlock");
|
|
12
|
+
const form = document.querySelector("#unlock-form");
|
|
13
|
+
const input = document.querySelector("#access-token");
|
|
14
|
+
const errorElement = document.querySelector("#unlock-error");
|
|
15
|
+
const welcome = document.querySelector("#welcome");
|
|
16
|
+
if (
|
|
17
|
+
!(panel instanceof HTMLElement) ||
|
|
18
|
+
!(form instanceof HTMLFormElement) ||
|
|
19
|
+
!(input instanceof HTMLInputElement) ||
|
|
20
|
+
!(errorElement instanceof HTMLElement) ||
|
|
21
|
+
!(welcome instanceof HTMLElement)
|
|
22
|
+
) {
|
|
23
|
+
throw new Error("Missing unlock form.");
|
|
24
|
+
}
|
|
25
|
+
welcome.hidden = true;
|
|
26
|
+
panel.hidden = false;
|
|
27
|
+
input.focus();
|
|
28
|
+
/** @type {PromiseWithResolvers<void>} */
|
|
29
|
+
const { promise, resolve } = Promise.withResolvers();
|
|
30
|
+
/** @param {Event} event - Browser submit event. */
|
|
31
|
+
const submit = async (event) => {
|
|
32
|
+
event.preventDefault();
|
|
33
|
+
try {
|
|
34
|
+
const result = await fetch("/api/session", {
|
|
35
|
+
body: JSON.stringify({ token: input.value.trim() }),
|
|
36
|
+
headers: { "Content-Type": "application/json" },
|
|
37
|
+
method: "POST",
|
|
38
|
+
});
|
|
39
|
+
if (!result.ok) {
|
|
40
|
+
throw new Error("The access token did not match. Try again.");
|
|
41
|
+
}
|
|
42
|
+
input.value = "";
|
|
43
|
+
panel.hidden = true;
|
|
44
|
+
welcome.hidden = false;
|
|
45
|
+
resolve();
|
|
46
|
+
} catch (error) {
|
|
47
|
+
errorElement.textContent =
|
|
48
|
+
error instanceof Error ? error.message : "Could not unlock viewer.";
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
form.addEventListener("submit", (event) => {
|
|
52
|
+
void submit(event);
|
|
53
|
+
});
|
|
54
|
+
await promise;
|
|
55
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/** @param {unknown} value - JSON value to inspect. @returns {value is Record<string, unknown>} */
|
|
2
|
+
export function isObject(value) {
|
|
3
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** @param {unknown} value - JSON value to inspect. @returns {value is string[]} */
|
|
7
|
+
function isStrings(value) {
|
|
8
|
+
return (
|
|
9
|
+
Array.isArray(value) && value.every((item) => typeof item === "string")
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** @param {unknown} value - Candidate field to inspect. @returns {value is import('../types.ts').Cell} */
|
|
14
|
+
function isCell(value) {
|
|
15
|
+
return (
|
|
16
|
+
value === null ||
|
|
17
|
+
typeof value === "string" ||
|
|
18
|
+
typeof value === "boolean" ||
|
|
19
|
+
(typeof value === "number" && Number.isFinite(value)) ||
|
|
20
|
+
isStrings(value)
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** @param {unknown} value - Candidate to inspect. @returns {value is import('../types.ts').Candidate} */
|
|
25
|
+
function isCandidate(value) {
|
|
26
|
+
return (
|
|
27
|
+
isObject(value) &&
|
|
28
|
+
typeof value.id === "string" &&
|
|
29
|
+
typeof value.name === "string" &&
|
|
30
|
+
Object.values(value).every(isCell)
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** @param {unknown} value - API response to inspect. @returns {value is import('../types.ts').View} */
|
|
35
|
+
function isView(value) {
|
|
36
|
+
if (!isObject(value) || !isObject(value.layout) || !isObject(value.state)) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
const { layout, state } = value;
|
|
40
|
+
return (
|
|
41
|
+
typeof value.id === "string" &&
|
|
42
|
+
typeof value.title === "string" &&
|
|
43
|
+
typeof value.revision === "number" &&
|
|
44
|
+
typeof value.updatedAt === "string" &&
|
|
45
|
+
Array.isArray(value.records) &&
|
|
46
|
+
value.records.every(isCandidate) &&
|
|
47
|
+
(layout.type === "table" || layout.type === "cards") &&
|
|
48
|
+
isStrings(layout.columns) &&
|
|
49
|
+
typeof state.filter === "string" &&
|
|
50
|
+
isStrings(state.selectedIds) &&
|
|
51
|
+
isStrings(state.removedIds) &&
|
|
52
|
+
isStrings(state.maybeIds) &&
|
|
53
|
+
typeof state.sortBy === "string" &&
|
|
54
|
+
(state.sortDirection === "asc" || state.sortDirection === "desc")
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** @param {unknown} value - Complete view response from the local API. */
|
|
59
|
+
export function readView(value) {
|
|
60
|
+
if (!isView(value)) {
|
|
61
|
+
throw new Error("The local server returned an invalid view.");
|
|
62
|
+
}
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** @param {unknown} value - List entry from the local API. @returns {value is {id: string, title: string, count: number}} */
|
|
67
|
+
function isSummary(value) {
|
|
68
|
+
return (
|
|
69
|
+
isObject(value) &&
|
|
70
|
+
typeof value.id === "string" &&
|
|
71
|
+
typeof value.title === "string" &&
|
|
72
|
+
typeof value.count === "number"
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** @param {unknown} value - List response from the local API. */
|
|
77
|
+
export function readSummaries(value) {
|
|
78
|
+
if (!Array.isArray(value) || !value.every(isSummary)) {
|
|
79
|
+
throw new Error("The local server returned an invalid view list.");
|
|
80
|
+
}
|
|
81
|
+
return value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** @template T @param {T[]} records - Filtered records in their existing order. @param {number} requestedPage - Zero-based page. */
|
|
85
|
+
export function pageOf(records, requestedPage) {
|
|
86
|
+
const size = 20;
|
|
87
|
+
const pages = Math.max(1, Math.ceil(records.length / size));
|
|
88
|
+
const page = Math.max(0, Math.min(requestedPage, pages - 1));
|
|
89
|
+
return {
|
|
90
|
+
end: Math.min((page + 1) * size, records.length),
|
|
91
|
+
page,
|
|
92
|
+
pages,
|
|
93
|
+
records: records.slice(page * size, (page + 1) * size),
|
|
94
|
+
start: records.length === 0 ? 0 : page * size + 1,
|
|
95
|
+
total: records.length,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** @param {import('../types.ts').Candidate[]} previous - Previous snapshot. @param {import('../types.ts').Candidate[]} next - Incoming snapshot. */
|
|
100
|
+
export function addedRecordIds(previous, next) {
|
|
101
|
+
const known = new Set(previous.map((record) => record.id));
|
|
102
|
+
return next
|
|
103
|
+
.filter((record) => !known.has(record.id))
|
|
104
|
+
.map((record) => record.id);
|
|
105
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Scout Mesh · Local viewer</title>
|
|
7
|
+
<link rel="stylesheet" href="/styles.css" />
|
|
8
|
+
<script type="module" src="/app.js"></script>
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<main>
|
|
12
|
+
<section id="unlock" class="welcome" hidden>
|
|
13
|
+
<h1>Unlock your candidate workspace</h1>
|
|
14
|
+
<p>Your agent can unlock this page using the local viewer token.</p>
|
|
15
|
+
<form id="unlock-form">
|
|
16
|
+
<label for="access-token">Local access token</label>
|
|
17
|
+
<input
|
|
18
|
+
id="access-token"
|
|
19
|
+
type="password"
|
|
20
|
+
autocomplete="off"
|
|
21
|
+
required
|
|
22
|
+
/>
|
|
23
|
+
<button class="button" type="submit">Unlock workspace</button>
|
|
24
|
+
<p id="unlock-error" role="alert"></p>
|
|
25
|
+
</form>
|
|
26
|
+
<small
|
|
27
|
+
>On this computer, run <code>scoutmesh-viewer token</code> to get the
|
|
28
|
+
token.</small
|
|
29
|
+
>
|
|
30
|
+
</section>
|
|
31
|
+
<section id="welcome" class="welcome">
|
|
32
|
+
<a class="brand" href="/">scout<span>mesh</span></a>
|
|
33
|
+
<h1>Your results, beyond the chat.</h1>
|
|
34
|
+
<p>
|
|
35
|
+
Ask your agent to send candidates to this viewer. Browse profiles here
|
|
36
|
+
while you keep the conversation going.
|
|
37
|
+
</p>
|
|
38
|
+
<div id="views" class="view-list"></div>
|
|
39
|
+
<div class="setup-note">
|
|
40
|
+
<span>Waiting for your first results?</span
|
|
41
|
+
><code>npm run viewer:demo</code
|
|
42
|
+
><small>Results and review decisions stay on this computer.</small>
|
|
43
|
+
</div>
|
|
44
|
+
</section>
|
|
45
|
+
<section id="workspace" hidden>
|
|
46
|
+
<h1 id="view-title"></h1>
|
|
47
|
+
<nav class="review-tabs" aria-label="Candidate lists">
|
|
48
|
+
<button id="list-candidates" type="button" aria-pressed="false">
|
|
49
|
+
All
|
|
50
|
+
</button>
|
|
51
|
+
<button id="list-unreviewed" type="button" aria-pressed="true">
|
|
52
|
+
Unreviewed
|
|
53
|
+
</button>
|
|
54
|
+
<button id="list-shortlist" type="button" aria-pressed="false">
|
|
55
|
+
Yes
|
|
56
|
+
</button>
|
|
57
|
+
<button id="list-maybe" type="button" aria-pressed="false">
|
|
58
|
+
Maybe
|
|
59
|
+
</button>
|
|
60
|
+
<button id="list-removed" type="button" aria-pressed="false">
|
|
61
|
+
No
|
|
62
|
+
</button>
|
|
63
|
+
</nav>
|
|
64
|
+
<p id="review-feedback" class="muted" role="status"></p>
|
|
65
|
+
<div id="undo-removal" class="undo-removal" hidden>
|
|
66
|
+
<span id="removal-message"></span>
|
|
67
|
+
<button id="undo-button" type="button" class="text-button">
|
|
68
|
+
Undo
|
|
69
|
+
</button>
|
|
70
|
+
</div>
|
|
71
|
+
<p
|
|
72
|
+
id="connection-warning"
|
|
73
|
+
class="connection-warning"
|
|
74
|
+
role="status"
|
|
75
|
+
hidden
|
|
76
|
+
>
|
|
77
|
+
Connection lost. Reconnecting…
|
|
78
|
+
</p>
|
|
79
|
+
<div id="new-batch" class="new-batch" hidden>
|
|
80
|
+
<span id="new-batch-count" role="status"></span>
|
|
81
|
+
<button id="view-new" class="button" type="button">View new</button>
|
|
82
|
+
</div>
|
|
83
|
+
<div id="batch-context" class="batch-context" hidden>
|
|
84
|
+
<strong>New candidates</strong>
|
|
85
|
+
<button id="back-to-candidates" class="text-button" type="button">
|
|
86
|
+
Back to candidates
|
|
87
|
+
</button>
|
|
88
|
+
</div>
|
|
89
|
+
<nav
|
|
90
|
+
id="pagination-top"
|
|
91
|
+
class="pagination"
|
|
92
|
+
aria-label="Candidate pages (top)"
|
|
93
|
+
hidden
|
|
94
|
+
>
|
|
95
|
+
<button id="previous-top" class="button" type="button">
|
|
96
|
+
Previous
|
|
97
|
+
</button>
|
|
98
|
+
<span id="page-range-top" tabindex="-1" role="status"></span>
|
|
99
|
+
<button id="next-top" class="button" type="button">Next</button>
|
|
100
|
+
</nav>
|
|
101
|
+
<div id="results" class="results"></div>
|
|
102
|
+
<nav
|
|
103
|
+
id="pagination-bottom"
|
|
104
|
+
class="pagination"
|
|
105
|
+
aria-label="Candidate pages (bottom)"
|
|
106
|
+
hidden
|
|
107
|
+
>
|
|
108
|
+
<button id="previous-bottom" class="button" type="button">
|
|
109
|
+
Previous
|
|
110
|
+
</button>
|
|
111
|
+
<span id="page-range-bottom" tabindex="-1" role="status"></span>
|
|
112
|
+
<button id="next-bottom" class="button" type="button">Next</button>
|
|
113
|
+
</nav>
|
|
114
|
+
</section>
|
|
115
|
+
<p id="error" role="alert" hidden></p>
|
|
116
|
+
</main>
|
|
117
|
+
<dialog id="profile" aria-labelledby="profile-name">
|
|
118
|
+
<div class="profile-top">
|
|
119
|
+
<nav class="profile-navigation" aria-label="Candidate navigation">
|
|
120
|
+
<button
|
|
121
|
+
id="profile-previous"
|
|
122
|
+
class="button quiet"
|
|
123
|
+
type="button"
|
|
124
|
+
aria-label="Previous candidate"
|
|
125
|
+
>
|
|
126
|
+
Previous
|
|
127
|
+
</button>
|
|
128
|
+
<span
|
|
129
|
+
id="profile-position"
|
|
130
|
+
role="status"
|
|
131
|
+
aria-live="polite"
|
|
132
|
+
aria-atomic="true"
|
|
133
|
+
></span>
|
|
134
|
+
<button
|
|
135
|
+
id="profile-next"
|
|
136
|
+
class="button quiet"
|
|
137
|
+
type="button"
|
|
138
|
+
aria-label="Next candidate"
|
|
139
|
+
>
|
|
140
|
+
Next
|
|
141
|
+
</button>
|
|
142
|
+
</nav>
|
|
143
|
+
<button id="close-profile" class="button quiet" type="button">
|
|
144
|
+
Close
|
|
145
|
+
</button>
|
|
146
|
+
</div>
|
|
147
|
+
<div id="profile-identity"></div>
|
|
148
|
+
<div id="profile-tabs" role="tablist" aria-label="Profile sections"></div>
|
|
149
|
+
<div id="profile-content" role="tabpanel" tabindex="0"></div>
|
|
150
|
+
<footer id="profile-actions" aria-label="Review decision"></footer>
|
|
151
|
+
</dialog>
|
|
152
|
+
</body>
|
|
153
|
+
</html>
|