Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 | 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 3x 3x 3x 835x 835x 1x 1x 1x 1x 1x 1x 1x 835x 835x 2x 2x 2x 2x 2x 2x 2x 1x 1x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 835x 835x 835x 18x 18x 18x 18x 18x 18x 18x 18x 18x 17x 17x 17x 17x 18x 18x 18x 18x 18x 18x 18x 18x 835x 835x 835x 835x 835x 835x 835x 835x 835x 35x 29x 35x 12x 12x 835x 835x 835x 835x 835x 835x 835x 34x 5x 5x 5x 5x 835x 835x 835x 835x 835x 835x 835x 835x 835x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 835x 835x 835x 835x 835x 835x 835x 835x 3x 1x 1x 1x 2x 835x 835x 835x 835x 835x 835x 835x 35x 3x 3x 2x 835x 835x 835x 835x 835x 835x 835x 835x 835x 33x 1x 1x 835x 835x 835x 29x 19x 19x 19x 3x 3x 3x 3x 3x 3x 3x 3x 3x 19x 16x 19x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 19x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 19x 29x 29x 835x 835x 835x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 835x 835x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 835x 752x 752x 752x 752x 752x 752x 752x 752x 752x 752x 83x 83x 83x 81x 81x 81x 81x 81x 81x 81x 81x 81x 81x 81x 81x 81x 1x 1x 1x 1x 81x 81x 81x 81x 81x 81x 81x 81x 81x 81x 83x 83x 83x 2x 2x 2x 2x 2x 2x 2x 2x 83x 83x 835x 835x 835x 835x 835x | import { useCallback, useEffect, useRef, useState } from "react";
import { TabBar } from "../shared/TabBar";
import { ThisPageTab } from "./ThisPageTab";
import { SavedTab } from "./SavedTab";
import { PasteComposer } from "./PasteComposer";
import type { JobView } from "./ThisPageTab";
import type { PasteView } from "./PasteComposer";
import { jobStorage } from "../../services/jobStorage";
import { excerptOf } from "../../lib/textExcerpt";
import { startCheckout } from "../../services/accountService";
import { MessageType } from "../../types/messages";
import type { ExtensionMessage } from "../../types/messages";
import type { SavedJob } from "../../types/job";
const TABS = [
{ id: "this-page", label: "This Page" },
{ id: "saved", label: "Saved" },
];
const INITIAL_VIEW: JobView = {
status: "idle",
analysis: null,
error: null,
fallback: null,
canonicalUrl: null,
sourceUrl: null,
multiplePostings: false,
cached: false,
saved: null,
};
const INITIAL_PASTE_VIEW: PasteView = {
status: "idle",
analysis: null,
error: null,
canonicalUrl: null,
saved: null,
};
interface JobPanelProps {
tabId: number | null;
/**
* Bumped by the background on every tab switch or navigation. Each bump
* means the activeTab grant (and thus any shown analysis) is stale.
*/
navigationId?: number;
/**
* Bumped on every toolbar-icon click, the gesture that grants activeTab.
* Each bump is a cue to (re-)analyze while the grant is fresh.
*/
analyzeNonce?: number;
/**
* Bumped on every tab switch. Each bump is a cue to silently probe the
* saved library / session cache for the revisited tab's analysis — the
* background only answers if it has one, so idle stays idle.
*/
probeNonce?: number;
}
export function JobPanel({
tabId,
navigationId = 0,
analyzeNonce = 0,
probeNonce = 0,
}: JobPanelProps) {
const [view, setView] = useState<JobView>(INITIAL_VIEW);
const [activeTab, setActiveTab] = useState<string>("this-page");
const [saveError, setSaveError] = useState<string | null>(null);
const cancelledRef = useRef(false);
const viewStatusRef = useRef(view.status);
viewStatusRef.current = view.status;
// T105: if a scoped (picked) analysis fails after the region was
// committed, Retry must re-run against that SAME region rather than
// silently falling back to automatic extraction — the user already
// corrected the extractor once. Cleared on navigation (a new page has no
// committed region) and overwritten by the next scoped commit. A
// selection-mechanism scoped analysis (the context-menu path) is never
// tracked here: it is issued directly by the background script, and by
// the time an error could show, the live page selection it depended on
// may no longer exist — there is nothing stable left to retry against.
const pendingScopedRegionRef = useRef<{ text: string; selector: string } | null>(null);
// Paste (spec FR-017): draft and result state live here, at the panel
// level, so they survive the composer unmounting/remounting on close/
// reopen (FR-017c) and are untouched by the navigation-reset effect below
// (FR-017d) — that effect only ever writes to `view`, never to these.
const [pasteOpen, setPasteOpen] = useState(false);
const [pasteDraft, setPasteDraft] = useState("");
const [pasteView, setPasteView] = useState<PasteView>(INITIAL_PASTE_VIEW);
const pasteButtonRef = useRef<HTMLButtonElement>(null);
const handleOpenPaste = () => setPasteOpen(true);
// FR-017b: closing (Escape or the close control) returns focus to the
// header button that opened it.
const handleClosePaste = () => {
setPasteOpen(false);
pasteButtonRef.current?.focus();
};
const submitPaste = (assumeJobPosting: boolean) => {
setPasteView((prev) => ({ ...prev, status: "analyzing", error: null }));
void chrome.runtime.sendMessage({
type: MessageType.ANALYZE_PASTED_TEXT,
text: pasteDraft,
assumeJobPosting,
});
};
const handleSavePaste = async () => {
if (!pasteView.analysis || !pasteView.canonicalUrl) return;
const now = new Date().toISOString();
// FR-020a: list surfaces must never show a blank title, a URL-shaped
// placeholder, or the raw paste:<sha256> key for a paste-sourced
// posting that extraction didn't title — an excerpt of what was
// actually pasted is always available and always meaningful.
const analysis = pasteView.analysis.title
? pasteView.analysis
: { ...pasteView.analysis, title: excerptOf(pasteDraft) };
const job: SavedJob = {
schemaVersion: 1,
canonicalUrl: pasteView.canonicalUrl,
sourceUrl: "",
source: "paste",
analysis,
status: "interested",
notes: "",
savedAt: now,
updatedAt: now,
};
try {
await jobStorage.save(job);
setPasteView((prev) => ({ ...prev, saved: job }));
} catch {
// The library-full/error surface already lives on the This Page tab;
// paste keeps this best-effort rather than duplicating that UI.
}
};
const requestAnalysis = useCallback(
(
options: {
assumeJobPosting?: boolean;
bypassCache?: boolean;
cachedOnly?: boolean;
} = {}
) => {
if (tabId === null) return;
cancelledRef.current = false;
if (!options.cachedOnly) {
// A cachedOnly probe may go unanswered, so it must not enter the
// "analyzing" state it could never leave.
setView((prev) => ({ ...prev, status: "analyzing", error: null }));
}
void chrome.runtime.sendMessage({
type: MessageType.ANALYZE_JOB_PAGE,
tabId,
assumeJobPosting: options.assumeJobPosting ?? false,
bypassCache: options.bypassCache ?? false,
cachedOnly: options.cachedOnly ?? false,
});
},
[tabId]
);
// Auto-trigger once on panel open (spec assumption: auto-analysis with
// cancel affordance). Only the panel-opening toolbar click carries an
// activeTab grant; navigationId > 0 means a navigation or tab switch
// happened since then, so an auto-attempt would fail by construction.
const autoTriggeredRef = useRef(false);
useEffect(() => {
if (autoTriggeredRef.current) return;
autoTriggeredRef.current = true;
if (tabId !== null && navigationId === 0) {
requestAnalysis();
}
}, [tabId, navigationId, requestAnalysis]);
// On navigation/tab switch, drop back to idle: the shown analysis belongs
// to the previous page, and FR-002 forbids auto-extraction without a user
// action. Late results for the old page are ignored via cancelledRef.
const navigationRef = useRef(navigationId);
useEffect(() => {
if (navigationId === navigationRef.current) return;
navigationRef.current = navigationId;
cancelledRef.current = true;
pendingScopedRegionRef.current = null;
setView(INITIAL_VIEW);
}, [navigationId]);
// FR-026/FR-026a: re-runs analysis against a hand-picked region, bypassing
// the cache (jobFlow.ts's analyzeScopedRegion always does). FR-007a/T095:
// the analyzing indicator must appear within 300ms of ANY analysis
// trigger, not just a page load, so this sets it synchronously exactly
// like requestAnalysis does, before the message is even sent.
const analyzeRegion = useCallback(
(result: { text: string; selector: string }) => {
if (tabId === null) return;
cancelledRef.current = false;
pendingScopedRegionRef.current = result;
setView((prev) => ({ ...prev, status: "analyzing", error: null }));
void chrome.runtime.sendMessage({
type: MessageType.ANALYZE_SCOPED_REGION,
tabId,
text: result.text,
selector: result.selector,
mechanism: "picked",
});
},
[tabId]
);
// Used for both idle's "Analyze this page" and the error card's Retry.
// Idle is only ever reached right after a navigation reset, which just
// cleared pendingScopedRegionRef above, so the branch below is only live
// for an error that followed a scoped commit (T105).
const retryAnalysis = useCallback(() => {
if (pendingScopedRegionRef.current) {
analyzeRegion(pendingScopedRegionRef.current);
return;
}
requestAnalysis();
}, [analyzeRegion, requestAnalysis]);
// Toolbar-icon click while the panel is open: analyze under the fresh
// activeTab grant. Skipped mid-analysis — when the same click both opened
// the panel and bumped the nonce, the mount effect already fired.
const analyzeNonceRef = useRef(analyzeNonce);
useEffect(() => {
if (analyzeNonce === analyzeNonceRef.current) return;
analyzeNonceRef.current = analyzeNonce;
if (viewStatusRef.current === "analyzing") return;
requestAnalysis();
}, [analyzeNonce, requestAnalysis]);
// Tab switch: after the navigation reset above, silently ask the background
// for a stored analysis of the revisited tab. It answers only from the
// library/cache (no page access, so no permission error); on a miss the
// panel simply stays idle. Declared after the reset effect so the probe's
// un-cancelling runs last.
const probeNonceRef = useRef(probeNonce);
useEffect(() => {
if (probeNonce === probeNonceRef.current) return;
probeNonceRef.current = probeNonce;
requestAnalysis({ cachedOnly: true });
}, [probeNonce, requestAnalysis]);
useEffect(() => {
const listener = (message: ExtensionMessage) => {
// Paste has no page-cancellation concept of its own — it is unaffected
// by cancelledRef, which only ever guards the This Page pipeline.
if (message.type === MessageType.PASTE_ANALYSIS_RESULT) {
setPasteView({
status: "ready",
analysis: message.analysis,
error: null,
canonicalUrl: message.canonicalUrl,
saved: message.saved,
});
return;
}
if (message.type === MessageType.PASTE_ANALYSIS_ERROR) {
setPasteView((prev) => ({
...prev,
status: "error",
analysis: null,
error: message.error,
}));
return;
}
if (cancelledRef.current) return;
if (message.type === MessageType.JOB_ANALYSIS_RESULT) {
setView({
status: "ready",
analysis: message.analysis,
error: null,
fallback: null,
canonicalUrl: message.canonicalUrl,
sourceUrl: message.sourceUrl,
multiplePostings: message.multiplePostings,
cached: message.cached,
saved: message.saved,
strategy: message.strategy,
scopedRegion: message.scopedRegion,
});
} else if (message.type === MessageType.JOB_ANALYSIS_ERROR) {
setView((prev) => ({
...prev,
status: "error",
analysis: null,
error: message.error,
fallback: message.fallback,
canonicalUrl: message.canonicalUrl,
sourceUrl: message.sourceUrl,
}));
}
};
chrome.runtime.onMessage.addListener(listener);
return () => chrome.runtime.onMessage.removeListener(listener);
}, []);
const handleCancel = () => {
cancelledRef.current = true;
// Tell the background to actually stop. Until readiness, cancelling only
// made the panel ignore the late result — the request was still sent and
// the user's allowance still spent. There is now a real window to cancel
// inside, so the request must not be sent at all (spec FR-006).
if (tabId !== null) {
// Promise.resolve so a sendMessage that returns void (older typings, and
// the test double) cannot throw on .catch.
void Promise.resolve(
chrome.runtime.sendMessage({ type: MessageType.CANCEL_ANALYSIS, tabId })
).catch(() => {
// Background asleep or already finished — the panel is idle either way.
});
}
setView((prev) => ({ ...prev, status: "idle" }));
};
const handleSave = async () => {
if (!view.analysis || !view.canonicalUrl) return;
const now = new Date().toISOString();
const job: SavedJob = {
schemaVersion: 1,
canonicalUrl: view.canonicalUrl,
sourceUrl: view.sourceUrl ?? view.canonicalUrl,
analysis: view.analysis,
status: "interested",
notes: "",
savedAt: now,
updatedAt: now,
};
try {
await jobStorage.save(job);
setSaveError(null);
setView((prev) => ({ ...prev, saved: job }));
} catch (err) {
setSaveError(
err instanceof Error ? err.message : "Saving failed. Please try again."
);
}
};
const handleExport = async () => {
const json = await jobStorage.exportAll();
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `saved-jobs-${new Date().toISOString().slice(0, 10)}.json`;
anchor.click();
URL.revokeObjectURL(url);
};
const handlePruneArchived = async () => {
await jobStorage.pruneArchived(50);
setSaveError(null);
};
const handleUpgrade = async () => {
try {
const { checkoutUrl } = await startCheckout();
window.open(checkoutUrl, "_blank", "noopener,noreferrer");
} catch {
// Checkout couldn't be created (already premium, Paddle unreachable,
// etc.) — AccountBar in the options page surfaces the specific error.
chrome.runtime.openOptionsPage();
}
};
return (
<>
<TabBar
tabs={TABS}
activeTab={activeTab}
onTabChange={setActiveTab}
tabsDisabled={pasteOpen}
>
<button
ref={pasteButtonRef}
onClick={handleOpenPaste}
aria-pressed={pasteOpen}
className="rounded-md px-3 py-1 text-sm font-medium text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
>
Paste text
</button>
</TabBar>
<div className="min-h-0 flex-1 overflow-hidden">
{pasteOpen ? (
<PasteComposer
draft={pasteDraft}
onDraftChange={setPasteDraft}
view={pasteView}
onClose={handleClosePaste}
onSubmit={() => submitPaste(false)}
onForceSubmit={() => submitPaste(true)}
onSave={() => void handleSavePaste()}
onUpgrade={() => void handleUpgrade()}
/>
) : (
<>
{activeTab === "this-page" && (
<div
id="panel-this-page"
role="tabpanel"
aria-labelledby="tab-this-page"
className="h-full overflow-y-auto"
>
<ThisPageTab
tabId={tabId}
view={view}
saveError={saveError}
onAnalyze={retryAnalysis}
onCancel={handleCancel}
onForceAnalyze={() =>
// bypassCache is load-bearing: the "not a job posting" verdict
// was just cached, and without it the lookup replays that
// verdict before assumeJobPosting ever reaches the backend.
requestAnalysis({ assumeJobPosting: true, bypassCache: true })
}
onReanalyze={() => requestAnalysis({ bypassCache: true })}
onSave={() => void handleSave()}
onExport={() => void handleExport()}
onPruneArchived={() => void handlePruneArchived()}
onUpgrade={() => void handleUpgrade()}
onOpenPaste={handleOpenPaste}
onRegionCommitted={analyzeRegion}
/>
</div>
)}
{activeTab === "saved" && (
<div
id="panel-saved"
role="tabpanel"
aria-labelledby="tab-saved"
className="h-full overflow-y-auto"
>
<SavedTab />
</div>
)}
</>
)}
</div>
</>
);
}
|