merge: wave 2 executor (01-02) into main

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 4.6 committed 2026-03-25 23:46:16 -04:00
1 parent a5b8fcf9d3
commit aa1a78d7d9
1081 files changed
+445341

No files matched your search

@@ -0,0 +1,104 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { Agent } from "@paperclipai/shared";
import {
AGENT_ORDER_UPDATED_EVENT,
getAgentOrderStorageKey,
readAgentOrder,
sortAgentsByStoredOrder,
writeAgentOrder,
} from "../lib/agent-order";
type UseAgentOrderParams = {
agents: Agent[];
companyId: string | null | undefined;
userId: string | null | undefined;
};
type AgentOrderUpdatedDetail = {
storageKey: string;
orderedIds: string[];
};
function areEqual(a: string[], b: string[]) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
if (a[i] !== b[i]) return false;
}
return true;
}
function buildOrderIds(agents: Agent[], orderedIds: string[]) {
return sortAgentsByStoredOrder(agents, orderedIds).map((agent) => agent.id);
}
export function useAgentOrder({ agents, companyId, userId }: UseAgentOrderParams) {
const storageKey = useMemo(() => {
if (!companyId) return null;
return getAgentOrderStorageKey(companyId, userId);
}, [companyId, userId]);
const [orderedIds, setOrderedIds] = useState<string[]>(() => {
if (!storageKey) return agents.map((agent) => agent.id);
return buildOrderIds(agents, readAgentOrder(storageKey));
});
useEffect(() => {
const nextIds = storageKey
? buildOrderIds(agents, readAgentOrder(storageKey))
: agents.map((agent) => agent.id);
setOrderedIds((current) => (areEqual(current, nextIds) ? current : nextIds));
}, [agents, storageKey]);
useEffect(() => {
if (!storageKey) return;
const syncFromIds = (ids: string[]) => {
const nextIds = buildOrderIds(agents, ids);
setOrderedIds((current) => (areEqual(current, nextIds) ? current : nextIds));
};
const onStorage = (event: StorageEvent) => {
if (event.key !== storageKey) return;
syncFromIds(readAgentOrder(storageKey));
};
const onCustomEvent = (event: Event) => {
const detail = (event as CustomEvent<AgentOrderUpdatedDetail>).detail;
if (!detail || detail.storageKey !== storageKey) return;
syncFromIds(detail.orderedIds);
};
window.addEventListener("storage", onStorage);
window.addEventListener(AGENT_ORDER_UPDATED_EVENT, onCustomEvent);
return () => {
window.removeEventListener("storage", onStorage);
window.removeEventListener(AGENT_ORDER_UPDATED_EVENT, onCustomEvent);
};
}, [agents, storageKey]);
const orderedAgents = useMemo(
() => sortAgentsByStoredOrder(agents, orderedIds),
[agents, orderedIds],
);
const persistOrder = useCallback(
(ids: string[]) => {
const idSet = new Set(agents.map((agent) => agent.id));
const filtered = ids.filter((id) => idSet.has(id));
for (const agent of sortAgentsByStoredOrder(agents, [])) {
if (!filtered.includes(agent.id)) filtered.push(agent.id);
}
setOrderedIds((current) => (areEqual(current, filtered) ? current : filtered));
if (storageKey) {
writeAgentOrder(storageKey, filtered);
}
},
[agents, storageKey],
);
return {
orderedAgents,
orderedIds,
persistOrder,
};
}
@@ -0,0 +1,72 @@
import { useCallback, useEffect, useRef, useState } from "react";
export type AutosaveState = "idle" | "saving" | "saved" | "error";
const SAVING_DELAY_MS = 250;
const SAVED_LINGER_MS = 1600;
export function useAutosaveIndicator() {
const [state, setState] = useState<AutosaveState>("idle");
const saveIdRef = useRef(0);
const savingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearSavedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimers = useCallback(() => {
if (savingTimerRef.current) {
clearTimeout(savingTimerRef.current);
savingTimerRef.current = null;
}
if (clearSavedTimerRef.current) {
clearTimeout(clearSavedTimerRef.current);
clearSavedTimerRef.current = null;
}
}, []);
useEffect(() => clearTimers, [clearTimers]);
const reset = useCallback(() => {
saveIdRef.current += 1;
clearTimers();
setState("idle");
}, [clearTimers]);
const markDirty = useCallback(() => {
clearTimers();
setState("idle");
}, [clearTimers]);
const runSave = useCallback(async (save: () => Promise<void>) => {
const saveId = saveIdRef.current + 1;
saveIdRef.current = saveId;
clearTimers();
savingTimerRef.current = setTimeout(() => {
if (saveIdRef.current === saveId) {
setState("saving");
}
}, SAVING_DELAY_MS);
try {
await save();
if (saveIdRef.current !== saveId) return;
clearTimers();
setState("saved");
clearSavedTimerRef.current = setTimeout(() => {
if (saveIdRef.current === saveId) {
setState("idle");
}
}, SAVED_LINGER_MS);
} catch (error) {
if (saveIdRef.current !== saveId) throw error;
clearTimers();
setState("error");
throw error;
}
}, [clearTimers]);
return {
state,
markDirty,
reset,
runSave,
};
}
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import {
getRememberedPathOwnerCompanyId,
sanitizeRememberedPathForCompany,
} from "../lib/company-page-memory";
const companies = [
{ id: "for", issuePrefix: "FOR" },
{ id: "pap", issuePrefix: "PAP" },
];
describe("getRememberedPathOwnerCompanyId", () => {
it("uses the route company instead of stale selected-company state for prefixed routes", () => {
expect(
getRememberedPathOwnerCompanyId({
companies,
pathname: "/FOR/issues/FOR-1",
fallbackCompanyId: "pap",
}),
).toBe("for");
});
it("skips saving when a prefixed route cannot yet be resolved to a known company", () => {
expect(
getRememberedPathOwnerCompanyId({
companies: [],
pathname: "/FOR/issues/FOR-1",
fallbackCompanyId: "pap",
}),
).toBeNull();
});
it("falls back to the previous company for unprefixed board routes", () => {
expect(
getRememberedPathOwnerCompanyId({
companies,
pathname: "/dashboard",
fallbackCompanyId: "pap",
}),
).toBe("pap");
});
it("treats unprefixed skills routes as board routes instead of company prefixes", () => {
expect(
getRememberedPathOwnerCompanyId({
companies,
pathname: "/skills/skill-123/files/SKILL.md",
fallbackCompanyId: "pap",
}),
).toBe("pap");
});
});
describe("sanitizeRememberedPathForCompany", () => {
it("keeps remembered issue paths that belong to the target company", () => {
expect(
sanitizeRememberedPathForCompany({
path: "/issues/PAP-12",
companyPrefix: "PAP",
}),
).toBe("/issues/PAP-12");
});
it("falls back to dashboard for remembered issue identifiers from another company", () => {
expect(
sanitizeRememberedPathForCompany({
path: "/issues/FOR-1",
companyPrefix: "PAP",
}),
).toBe("/dashboard");
});
it("falls back to dashboard when no remembered path exists", () => {
expect(
sanitizeRememberedPathForCompany({
path: null,
companyPrefix: "PAP",
}),
).toBe("/dashboard");
});
it("keeps remembered skills paths intact for the target company", () => {
expect(
sanitizeRememberedPathForCompany({
path: "/skills/skill-123/files/SKILL.md",
companyPrefix: "PAP",
}),
).toBe("/skills/skill-123/files/SKILL.md");
});
});
@@ -0,0 +1,79 @@
import { useEffect, useMemo, useRef } from "react";
import { useLocation, useNavigate } from "@/lib/router";
import { useCompany } from "../context/CompanyContext";
import { toCompanyRelativePath } from "../lib/company-routes";
import {
getRememberedPathOwnerCompanyId,
isRememberableCompanyPath,
sanitizeRememberedPathForCompany,
} from "../lib/company-page-memory";
const STORAGE_KEY = "paperclip.companyPaths";
function getCompanyPaths(): Record<string, string> {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw);
} catch {
/* ignore */
}
return {};
}
function saveCompanyPath(companyId: string, path: string) {
const paths = getCompanyPaths();
paths[companyId] = path;
localStorage.setItem(STORAGE_KEY, JSON.stringify(paths));
}
/**
* Remembers the last visited page per company and navigates to it on company switch.
* Falls back to /dashboard if no page was previously visited for a company.
*/
export function useCompanyPageMemory() {
const { companies, selectedCompanyId, selectedCompany, selectionSource } = useCompany();
const location = useLocation();
const navigate = useNavigate();
const prevCompanyId = useRef<string | null>(selectedCompanyId);
const rememberedPathOwnerCompanyId = useMemo(
() =>
getRememberedPathOwnerCompanyId({
companies,
pathname: location.pathname,
fallbackCompanyId: prevCompanyId.current,
}),
[companies, location.pathname],
);
// Save current path for current company on every location change.
// Uses prevCompanyId ref so we save under the correct company even
// during the render where selectedCompanyId has already changed.
const fullPath = location.pathname + location.search;
useEffect(() => {
const companyId = rememberedPathOwnerCompanyId;
const relativePath = toCompanyRelativePath(fullPath);
if (companyId && isRememberableCompanyPath(relativePath)) {
saveCompanyPath(companyId, relativePath);
}
}, [fullPath, rememberedPathOwnerCompanyId]);
// Navigate to saved path when company changes
useEffect(() => {
if (!selectedCompanyId) return;
if (
prevCompanyId.current !== null &&
selectedCompanyId !== prevCompanyId.current
) {
if (selectionSource !== "route_sync" && selectedCompany) {
const paths = getCompanyPaths();
const targetPath = sanitizeRememberedPathForCompany({
path: paths[selectedCompanyId],
companyPrefix: selectedCompany.issuePrefix,
});
navigate(`/${selectedCompany.issuePrefix}${targetPath}`, { replace: true });
}
}
prevCompanyId.current = selectedCompanyId;
}, [selectedCompany, selectedCompanyId, selectionSource, navigate]);
}
@@ -0,0 +1,120 @@
import { useEffect, useMemo, useRef, useState } from "react";
export type DatePreset = "mtd" | "7d" | "30d" | "ytd" | "all" | "custom";
export const PRESET_LABELS: Record<DatePreset, string> = {
mtd: "Month to Date",
"7d": "Last 7 Days",
"30d": "Last 30 Days",
ytd: "Year to Date",
all: "All Time",
custom: "Custom",
};
export const PRESET_KEYS: DatePreset[] = ["mtd", "7d", "30d", "ytd", "all", "custom"];
// note: computeRange is called inside a useMemo that re-evaluates once per minute
// (driven by minuteTick). this means sliding windows (7d, 30d) advance their upper
// bound at most once per minute — acceptable for a cost dashboard.
function computeRange(preset: DatePreset): { from: string; to: string } {
const now = new Date();
const to = now.toISOString();
switch (preset) {
case "mtd": {
const d = new Date(now.getFullYear(), now.getMonth(), 1);
return { from: d.toISOString(), to };
}
case "7d": {
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7, 0, 0, 0, 0);
return { from: d.toISOString(), to };
}
case "30d": {
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 30, 0, 0, 0, 0);
return { from: d.toISOString(), to };
}
case "ytd": {
const d = new Date(now.getFullYear(), 0, 1);
return { from: d.toISOString(), to };
}
case "all":
case "custom":
return { from: "", to: "" };
}
}
// floor a Date to the nearest minute so the query key is stable across
// 30s refetch ticks (prevents new cache entries on every poll cycle)
function floorToMinute(d: Date): string {
const floored = new Date(d);
floored.setSeconds(0, 0);
return floored.toISOString();
}
export interface UseDateRangeResult {
preset: DatePreset;
setPreset: (p: DatePreset) => void;
customFrom: string;
setCustomFrom: (v: string) => void;
customTo: string;
setCustomTo: (v: string) => void;
/** resolved iso strings ready to pass to api calls; empty string means unbounded */
from: string;
to: string;
/** false when preset=custom but both dates are not yet selected */
customReady: boolean;
}
export function useDateRange(): UseDateRangeResult {
const [preset, setPreset] = useState<DatePreset>("mtd");
const [customFrom, setCustomFrom] = useState("");
const [customTo, setCustomTo] = useState("");
// tick at the next calendar minute boundary, then every 60s, so sliding presets
// (7d, 30d) advance their upper bound in sync with wall clock minutes rather than
// drifting by the mount offset.
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [minuteTick, setMinuteTick] = useState(() => floorToMinute(new Date()));
useEffect(() => {
const now = new Date();
const msToNextMinute = (60 - now.getSeconds()) * 1000 - now.getMilliseconds();
const timeout = setTimeout(() => {
setMinuteTick(floorToMinute(new Date()));
intervalRef.current = setInterval(
() => setMinuteTick(floorToMinute(new Date())),
60_000,
);
}, msToNextMinute);
return () => {
clearTimeout(timeout);
if (intervalRef.current != null) clearInterval(intervalRef.current);
};
}, []);
const { from, to } = useMemo(() => {
if (preset !== "custom") return computeRange(preset);
// treat custom date strings as local-date boundaries so the full day is included
// regardless of the user's timezone. "from" starts at local midnight, "to" at 23:59:59.999.
const fromDate = customFrom ? new Date(customFrom + "T00:00:00") : null;
const toDate = customTo ? new Date(customTo + "T23:59:59.999") : null;
return {
from: fromDate ? fromDate.toISOString() : "",
to: toDate ? toDate.toISOString() : "",
};
// minuteTick drives re-evaluation of sliding presets once per minute.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [preset, customFrom, customTo, minuteTick]);
const customReady = preset !== "custom" || (!!customFrom && !!customTo);
return {
preset,
setPreset,
customFrom,
setCustomFrom,
customTo,
setCustomTo,
from,
to,
customReady,
};
}
@@ -0,0 +1,108 @@
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { accessApi } from "../api/access";
import { ApiError } from "../api/client";
import { approvalsApi } from "../api/approvals";
import { dashboardApi } from "../api/dashboard";
import { heartbeatsApi } from "../api/heartbeats";
import { issuesApi } from "../api/issues";
import { queryKeys } from "../lib/queryKeys";
import {
computeInboxBadgeData,
getRecentTouchedIssues,
loadDismissedInboxItems,
saveDismissedInboxItems,
getUnreadTouchedIssues,
} from "../lib/inbox";
const INBOX_ISSUE_STATUSES = "backlog,todo,in_progress,in_review,blocked,done";
export function useDismissedInboxItems() {
const [dismissed, setDismissed] = useState<Set<string>>(loadDismissedInboxItems);
useEffect(() => {
const handleStorage = (event: StorageEvent) => {
if (event.key !== "paperclip:inbox:dismissed") return;
setDismissed(loadDismissedInboxItems());
};
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}, []);
const dismiss = (id: string) => {
setDismissed((prev) => {
const next = new Set(prev);
next.add(id);
saveDismissedInboxItems(next);
return next;
});
};
return { dismissed, dismiss };
}
export function useInboxBadge(companyId: string | null | undefined) {
const { dismissed } = useDismissedInboxItems();
const { data: approvals = [] } = useQuery({
queryKey: queryKeys.approvals.list(companyId!),
queryFn: () => approvalsApi.list(companyId!),
enabled: !!companyId,
});
const { data: joinRequests = [] } = useQuery({
queryKey: queryKeys.access.joinRequests(companyId!),
queryFn: async () => {
try {
return await accessApi.listJoinRequests(companyId!, "pending_approval");
} catch (err) {
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
return [];
}
throw err;
}
},
enabled: !!companyId,
retry: false,
});
const { data: dashboard } = useQuery({
queryKey: queryKeys.dashboard(companyId!),
queryFn: () => dashboardApi.summary(companyId!),
enabled: !!companyId,
});
const { data: touchedIssues = [] } = useQuery({
queryKey: queryKeys.issues.listTouchedByMe(companyId!),
queryFn: () =>
issuesApi.list(companyId!, {
touchedByUserId: "me",
status: INBOX_ISSUE_STATUSES,
}),
enabled: !!companyId,
});
const unreadIssues = useMemo(
() => getUnreadTouchedIssues(getRecentTouchedIssues(touchedIssues)),
[touchedIssues],
);
const { data: heartbeatRuns = [] } = useQuery({
queryKey: queryKeys.heartbeats(companyId!),
queryFn: () => heartbeatsApi.list(companyId!),
enabled: !!companyId,
});
return useMemo(
() =>
computeInboxBadgeData({
approvals,
joinRequests,
dashboard,
heartbeatRuns,
unreadIssues,
dismissed,
}),
[approvals, joinRequests, dashboard, heartbeatRuns, unreadIssues, dismissed],
);
}
@@ -0,0 +1,40 @@
import { useEffect } from "react";
interface ShortcutHandlers {
onNewIssue?: () => void;
onToggleSidebar?: () => void;
onTogglePanel?: () => void;
}
export function useKeyboardShortcuts({ onNewIssue, onToggleSidebar, onTogglePanel }: ShortcutHandlers) {
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
// Don't fire shortcuts when typing in inputs
const target = e.target as HTMLElement;
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) {
return;
}
// C → New Issue
if (e.key === "c" && !e.metaKey && !e.ctrlKey && !e.altKey) {
e.preventDefault();
onNewIssue?.();
}
// [ → Toggle Sidebar
if (e.key === "[" && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
onToggleSidebar?.();
}
// ] → Toggle Panel
if (e.key === "]" && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
onTogglePanel?.();
}
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onNewIssue, onToggleSidebar, onTogglePanel]);
}
@@ -0,0 +1,105 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { Project } from "@paperclipai/shared";
import {
getProjectOrderStorageKey,
PROJECT_ORDER_UPDATED_EVENT,
readProjectOrder,
sortProjectsByStoredOrder,
writeProjectOrder,
} from "../lib/project-order";
type UseProjectOrderParams = {
projects: Project[];
companyId: string | null | undefined;
userId: string | null | undefined;
};
type ProjectOrderUpdatedDetail = {
storageKey: string;
orderedIds: string[];
};
function areEqual(a: string[], b: string[]) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
if (a[i] !== b[i]) return false;
}
return true;
}
function buildOrderIds(projects: Project[], orderedIds: string[]) {
return sortProjectsByStoredOrder(projects, orderedIds).map((project) => project.id);
}
export function useProjectOrder({ projects, companyId, userId }: UseProjectOrderParams) {
const storageKey = useMemo(() => {
if (!companyId) return null;
return getProjectOrderStorageKey(companyId, userId);
}, [companyId, userId]);
const [orderedIds, setOrderedIds] = useState<string[]>(() => {
if (!storageKey) return projects.map((project) => project.id);
return buildOrderIds(projects, readProjectOrder(storageKey));
});
useEffect(() => {
const nextIds = storageKey
? buildOrderIds(projects, readProjectOrder(storageKey))
: projects.map((project) => project.id);
setOrderedIds((current) => (areEqual(current, nextIds) ? current : nextIds));
}, [projects, storageKey]);
useEffect(() => {
if (!storageKey) return;
const syncFromIds = (ids: string[]) => {
const nextIds = buildOrderIds(projects, ids);
setOrderedIds((current) => (areEqual(current, nextIds) ? current : nextIds));
};
const onStorage = (event: StorageEvent) => {
if (event.key !== storageKey) return;
syncFromIds(readProjectOrder(storageKey));
};
const onCustomEvent = (event: Event) => {
const detail = (event as CustomEvent<ProjectOrderUpdatedDetail>).detail;
if (!detail || detail.storageKey !== storageKey) return;
syncFromIds(detail.orderedIds);
};
window.addEventListener("storage", onStorage);
window.addEventListener(PROJECT_ORDER_UPDATED_EVENT, onCustomEvent);
return () => {
window.removeEventListener("storage", onStorage);
window.removeEventListener(PROJECT_ORDER_UPDATED_EVENT, onCustomEvent);
};
}, [projects, storageKey]);
const orderedProjects = useMemo(
() => sortProjectsByStoredOrder(projects, orderedIds),
[projects, orderedIds],
);
const persistOrder = useCallback(
(ids: string[]) => {
const idSet = new Set(projects.map((project) => project.id));
const filtered = ids.filter((id) => idSet.has(id));
for (const project of projects) {
if (!filtered.includes(project.id)) filtered.push(project.id);
}
setOrderedIds((current) => (areEqual(current, filtered) ? current : filtered));
if (storageKey) {
writeProjectOrder(storageKey, filtered);
}
},
[projects, storageKey],
);
return {
orderedProjects,
orderedIds,
persistOrder,
};
}