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:
1 parent
a5b8fcf9d3
commit
aa1a78d7d9
1081 files changed
+445341
No files matched your search
@@ -0,0 +1,44 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
export interface Breadcrumb {
|
||||
label: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
interface BreadcrumbContextValue {
|
||||
breadcrumbs: Breadcrumb[];
|
||||
setBreadcrumbs: (crumbs: Breadcrumb[]) => void;
|
||||
}
|
||||
|
||||
const BreadcrumbContext = createContext<BreadcrumbContextValue | null>(null);
|
||||
|
||||
export function BreadcrumbProvider({ children }: { children: ReactNode }) {
|
||||
const [breadcrumbs, setBreadcrumbsState] = useState<Breadcrumb[]>([]);
|
||||
|
||||
const setBreadcrumbs = useCallback((crumbs: Breadcrumb[]) => {
|
||||
setBreadcrumbsState(crumbs);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (breadcrumbs.length === 0) {
|
||||
document.title = "Paperclip";
|
||||
} else {
|
||||
const parts = [...breadcrumbs].reverse().map((b) => b.label);
|
||||
document.title = `${parts.join(" · ")} · Paperclip`;
|
||||
}
|
||||
}, [breadcrumbs]);
|
||||
|
||||
return (
|
||||
<BreadcrumbContext.Provider value={{ breadcrumbs, setBreadcrumbs }}>
|
||||
{children}
|
||||
</BreadcrumbContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBreadcrumbs() {
|
||||
const ctx = useContext(BreadcrumbContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useBreadcrumbs must be used within BreadcrumbProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Company } from "@paperclipai/shared";
|
||||
import { companiesApi } from "../api/companies";
|
||||
import { ApiError } from "../api/client";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import type { CompanySelectionSource } from "../lib/company-selection";
|
||||
type CompanySelectionOptions = { source?: CompanySelectionSource };
|
||||
|
||||
interface CompanyContextValue {
|
||||
companies: Company[];
|
||||
selectedCompanyId: string | null;
|
||||
selectedCompany: Company | null;
|
||||
selectionSource: CompanySelectionSource;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
setSelectedCompanyId: (companyId: string, options?: CompanySelectionOptions) => void;
|
||||
reloadCompanies: () => Promise<void>;
|
||||
createCompany: (data: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
budgetMonthlyCents?: number;
|
||||
}) => Promise<Company>;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "paperclip.selectedCompanyId";
|
||||
|
||||
const CompanyContext = createContext<CompanyContextValue | null>(null);
|
||||
|
||||
export function CompanyProvider({ children }: { children: ReactNode }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectionSource, setSelectionSource] = useState<CompanySelectionSource>("bootstrap");
|
||||
const [selectedCompanyId, setSelectedCompanyIdState] = useState<string | null>(() => localStorage.getItem(STORAGE_KEY));
|
||||
|
||||
const { data: companies = [], isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.companies.all,
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await companiesApi.list();
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
const sidebarCompanies = useMemo(
|
||||
() => companies.filter((company) => company.status !== "archived"),
|
||||
[companies],
|
||||
);
|
||||
|
||||
// Auto-select first company when list loads
|
||||
useEffect(() => {
|
||||
if (companies.length === 0) return;
|
||||
|
||||
const selectableCompanies = sidebarCompanies.length > 0 ? sidebarCompanies : companies;
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored && selectableCompanies.some((c) => c.id === stored)) return;
|
||||
if (selectedCompanyId && selectableCompanies.some((c) => c.id === selectedCompanyId)) return;
|
||||
|
||||
const next = selectableCompanies[0]!.id;
|
||||
setSelectedCompanyIdState(next);
|
||||
setSelectionSource("bootstrap");
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
}, [companies, selectedCompanyId, sidebarCompanies]);
|
||||
|
||||
const setSelectedCompanyId = useCallback((companyId: string, options?: CompanySelectionOptions) => {
|
||||
setSelectedCompanyIdState(companyId);
|
||||
setSelectionSource(options?.source ?? "manual");
|
||||
localStorage.setItem(STORAGE_KEY, companyId);
|
||||
}, []);
|
||||
|
||||
const reloadCompanies = useCallback(async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
||||
}, [queryClient]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
budgetMonthlyCents?: number;
|
||||
}) =>
|
||||
companiesApi.create(data),
|
||||
onSuccess: (company) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
||||
setSelectedCompanyId(company.id);
|
||||
},
|
||||
});
|
||||
|
||||
const createCompany = useCallback(
|
||||
async (data: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
budgetMonthlyCents?: number;
|
||||
}) => {
|
||||
return createMutation.mutateAsync(data);
|
||||
},
|
||||
[createMutation],
|
||||
);
|
||||
|
||||
const selectedCompany = useMemo(
|
||||
() => companies.find((company) => company.id === selectedCompanyId) ?? null,
|
||||
[companies, selectedCompanyId],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
companies,
|
||||
selectedCompanyId,
|
||||
selectedCompany,
|
||||
selectionSource,
|
||||
loading: isLoading,
|
||||
error: error as Error | null,
|
||||
setSelectedCompanyId,
|
||||
reloadCompanies,
|
||||
createCompany,
|
||||
}),
|
||||
[
|
||||
companies,
|
||||
selectedCompanyId,
|
||||
selectedCompany,
|
||||
selectionSource,
|
||||
isLoading,
|
||||
error,
|
||||
setSelectedCompanyId,
|
||||
reloadCompanies,
|
||||
createCompany,
|
||||
],
|
||||
);
|
||||
|
||||
return <CompanyContext.Provider value={value}>{children}</CompanyContext.Provider>;
|
||||
}
|
||||
|
||||
export function useCompany() {
|
||||
const ctx = useContext(CompanyContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useCompany must be used within CompanyProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
||||
|
||||
interface NewIssueDefaults {
|
||||
status?: string;
|
||||
priority?: string;
|
||||
projectId?: string;
|
||||
assigneeAgentId?: string;
|
||||
assigneeUserId?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface NewGoalDefaults {
|
||||
parentId?: string;
|
||||
}
|
||||
|
||||
interface OnboardingOptions {
|
||||
initialStep?: 1 | 2 | 3 | 4;
|
||||
companyId?: string;
|
||||
}
|
||||
|
||||
interface DialogContextValue {
|
||||
newIssueOpen: boolean;
|
||||
newIssueDefaults: NewIssueDefaults;
|
||||
openNewIssue: (defaults?: NewIssueDefaults) => void;
|
||||
closeNewIssue: () => void;
|
||||
newProjectOpen: boolean;
|
||||
openNewProject: () => void;
|
||||
closeNewProject: () => void;
|
||||
newGoalOpen: boolean;
|
||||
newGoalDefaults: NewGoalDefaults;
|
||||
openNewGoal: (defaults?: NewGoalDefaults) => void;
|
||||
closeNewGoal: () => void;
|
||||
newAgentOpen: boolean;
|
||||
openNewAgent: () => void;
|
||||
closeNewAgent: () => void;
|
||||
onboardingOpen: boolean;
|
||||
onboardingOptions: OnboardingOptions;
|
||||
openOnboarding: (options?: OnboardingOptions) => void;
|
||||
closeOnboarding: () => void;
|
||||
}
|
||||
|
||||
const DialogContext = createContext<DialogContextValue | null>(null);
|
||||
|
||||
export function DialogProvider({ children }: { children: ReactNode }) {
|
||||
const [newIssueOpen, setNewIssueOpen] = useState(false);
|
||||
const [newIssueDefaults, setNewIssueDefaults] = useState<NewIssueDefaults>({});
|
||||
const [newProjectOpen, setNewProjectOpen] = useState(false);
|
||||
const [newGoalOpen, setNewGoalOpen] = useState(false);
|
||||
const [newGoalDefaults, setNewGoalDefaults] = useState<NewGoalDefaults>({});
|
||||
const [newAgentOpen, setNewAgentOpen] = useState(false);
|
||||
const [onboardingOpen, setOnboardingOpen] = useState(false);
|
||||
const [onboardingOptions, setOnboardingOptions] = useState<OnboardingOptions>({});
|
||||
|
||||
const openNewIssue = useCallback((defaults: NewIssueDefaults = {}) => {
|
||||
setNewIssueDefaults(defaults);
|
||||
setNewIssueOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeNewIssue = useCallback(() => {
|
||||
setNewIssueOpen(false);
|
||||
setNewIssueDefaults({});
|
||||
}, []);
|
||||
|
||||
const openNewProject = useCallback(() => {
|
||||
setNewProjectOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeNewProject = useCallback(() => {
|
||||
setNewProjectOpen(false);
|
||||
}, []);
|
||||
|
||||
const openNewGoal = useCallback((defaults: NewGoalDefaults = {}) => {
|
||||
setNewGoalDefaults(defaults);
|
||||
setNewGoalOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeNewGoal = useCallback(() => {
|
||||
setNewGoalOpen(false);
|
||||
setNewGoalDefaults({});
|
||||
}, []);
|
||||
|
||||
const openNewAgent = useCallback(() => {
|
||||
setNewAgentOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeNewAgent = useCallback(() => {
|
||||
setNewAgentOpen(false);
|
||||
}, []);
|
||||
|
||||
const openOnboarding = useCallback((options: OnboardingOptions = {}) => {
|
||||
setOnboardingOptions(options);
|
||||
setOnboardingOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeOnboarding = useCallback(() => {
|
||||
setOnboardingOpen(false);
|
||||
setOnboardingOptions({});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<DialogContext.Provider
|
||||
value={{
|
||||
newIssueOpen,
|
||||
newIssueDefaults,
|
||||
openNewIssue,
|
||||
closeNewIssue,
|
||||
newProjectOpen,
|
||||
openNewProject,
|
||||
closeNewProject,
|
||||
newGoalOpen,
|
||||
newGoalDefaults,
|
||||
openNewGoal,
|
||||
closeNewGoal,
|
||||
newAgentOpen,
|
||||
openNewAgent,
|
||||
closeNewAgent,
|
||||
onboardingOpen,
|
||||
onboardingOptions,
|
||||
openOnboarding,
|
||||
closeOnboarding,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</DialogContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDialog() {
|
||||
const ctx = useContext(DialogContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useDialog must be used within DialogProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { __liveUpdatesTestUtils } from "./LiveUpdatesProvider";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
|
||||
describe("LiveUpdatesProvider issue invalidation", () => {
|
||||
it("refreshes touched inbox queries for issue activity", () => {
|
||||
const invalidations: unknown[] = [];
|
||||
const queryClient = {
|
||||
invalidateQueries: (input: unknown) => {
|
||||
invalidations.push(input);
|
||||
},
|
||||
getQueryData: () => undefined,
|
||||
};
|
||||
|
||||
__liveUpdatesTestUtils.invalidateActivityQueries(
|
||||
queryClient as never,
|
||||
"company-1",
|
||||
{
|
||||
entityType: "issue",
|
||||
entityId: "issue-1",
|
||||
details: null,
|
||||
},
|
||||
);
|
||||
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: queryKeys.issues.listTouchedByMe("company-1"),
|
||||
});
|
||||
expect(invalidations).toContainEqual({
|
||||
queryKey: queryKeys.issues.listUnreadTouchedByMe("company-1"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("LiveUpdatesProvider visible issue toast suppression", () => {
|
||||
it("suppresses activity toasts for the issue page currently in view", () => {
|
||||
const queryClient = {
|
||||
getQueryData: (key: unknown) => {
|
||||
if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.detail("PAP-759"))) {
|
||||
return {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-759",
|
||||
assigneeAgentId: "agent-1",
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
__liveUpdatesTestUtils.shouldSuppressActivityToastForVisibleIssue(
|
||||
queryClient as never,
|
||||
"/PAP/issues/PAP-759",
|
||||
{
|
||||
entityType: "issue",
|
||||
entityId: "issue-1",
|
||||
details: { identifier: "PAP-759" },
|
||||
},
|
||||
{ isForegrounded: true },
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
__liveUpdatesTestUtils.shouldSuppressActivityToastForVisibleIssue(
|
||||
queryClient as never,
|
||||
"/PAP/issues/PAP-759",
|
||||
{
|
||||
entityType: "issue",
|
||||
entityId: "issue-2",
|
||||
details: { identifier: "PAP-760" },
|
||||
},
|
||||
{ isForegrounded: true },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("suppresses run and agent status toasts for the assignee of the visible issue", () => {
|
||||
const queryClient = {
|
||||
getQueryData: (key: unknown) => {
|
||||
if (JSON.stringify(key) === JSON.stringify(queryKeys.issues.detail("PAP-759"))) {
|
||||
return {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-759",
|
||||
assigneeAgentId: "agent-1",
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
__liveUpdatesTestUtils.shouldSuppressRunStatusToastForVisibleIssue(
|
||||
queryClient as never,
|
||||
"/PAP/issues/PAP-759",
|
||||
{
|
||||
runId: "run-1",
|
||||
agentId: "agent-1",
|
||||
},
|
||||
{ isForegrounded: true },
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
__liveUpdatesTestUtils.shouldSuppressAgentStatusToastForVisibleIssue(
|
||||
queryClient as never,
|
||||
"/PAP/issues/PAP-759",
|
||||
{
|
||||
agentId: "agent-1",
|
||||
status: "running",
|
||||
},
|
||||
{ isForegrounded: true },
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,758 @@
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import type { Agent, Issue, LiveEvent } from "@paperclipai/shared";
|
||||
import type { RunForIssue } from "../api/activity";
|
||||
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
|
||||
import { authApi } from "../api/auth";
|
||||
import { useCompany } from "./CompanyContext";
|
||||
import type { ToastInput } from "./ToastContext";
|
||||
import { useToast } from "./ToastContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { toCompanyRelativePath } from "../lib/company-routes";
|
||||
import { useLocation } from "../lib/router";
|
||||
|
||||
const TOAST_COOLDOWN_WINDOW_MS = 10_000;
|
||||
const TOAST_COOLDOWN_MAX = 3;
|
||||
const RECONNECT_SUPPRESS_MS = 2000;
|
||||
|
||||
function readString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function readRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function shortId(value: string) {
|
||||
return value.slice(0, 8);
|
||||
}
|
||||
|
||||
function resolveAgentName(
|
||||
queryClient: QueryClient,
|
||||
companyId: string,
|
||||
agentId: string,
|
||||
): string | null {
|
||||
const agents = queryClient.getQueryData<Agent[]>(queryKeys.agents.list(companyId));
|
||||
if (!agents) return null;
|
||||
const agent = agents.find((a) => a.id === agentId);
|
||||
return agent?.name ?? null;
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
if (text.length <= max) return text;
|
||||
return text.slice(0, max - 1) + "\u2026";
|
||||
}
|
||||
|
||||
function resolveActorLabel(
|
||||
queryClient: QueryClient,
|
||||
companyId: string,
|
||||
actorType: string | null,
|
||||
actorId: string | null,
|
||||
): string {
|
||||
if (actorType === "agent" && actorId) {
|
||||
return resolveAgentName(queryClient, companyId, actorId) ?? `Agent ${shortId(actorId)}`;
|
||||
}
|
||||
if (actorType === "system") return "System";
|
||||
if (actorType === "user" && actorId) {
|
||||
return "Board";
|
||||
}
|
||||
return "Someone";
|
||||
}
|
||||
|
||||
interface IssueToastContext {
|
||||
ref: string;
|
||||
title: string | null;
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
interface VisibleRouteOptions {
|
||||
isForegrounded?: boolean;
|
||||
}
|
||||
|
||||
interface VisibleIssueRouteContext {
|
||||
issueRefs: Set<string>;
|
||||
assigneeAgentId: string | null;
|
||||
runIds: Set<string>;
|
||||
}
|
||||
|
||||
function resolveIssueQueryRefs(
|
||||
queryClient: QueryClient,
|
||||
companyId: string,
|
||||
issueId: string,
|
||||
details: Record<string, unknown> | null,
|
||||
): string[] {
|
||||
const refs = new Set<string>([issueId]);
|
||||
const detailIssue = queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueId));
|
||||
const listIssues = queryClient.getQueryData<Issue[]>(queryKeys.issues.list(companyId));
|
||||
const detailsIdentifier =
|
||||
readString(details?.identifier) ??
|
||||
readString(details?.issueIdentifier);
|
||||
|
||||
if (detailsIdentifier) refs.add(detailsIdentifier);
|
||||
|
||||
if (detailIssue?.id) refs.add(detailIssue.id);
|
||||
if (detailIssue?.identifier) refs.add(detailIssue.identifier);
|
||||
|
||||
const listIssue = listIssues?.find((issue) => {
|
||||
if (issue.id === issueId) return true;
|
||||
if (issue.identifier && issue.identifier === issueId) return true;
|
||||
if (detailsIdentifier && issue.identifier === detailsIdentifier) return true;
|
||||
return false;
|
||||
});
|
||||
if (listIssue?.id) refs.add(listIssue.id);
|
||||
if (listIssue?.identifier) refs.add(listIssue.identifier);
|
||||
|
||||
return Array.from(refs);
|
||||
}
|
||||
|
||||
function resolveIssueToastContext(
|
||||
queryClient: QueryClient,
|
||||
companyId: string,
|
||||
issueId: string,
|
||||
details: Record<string, unknown> | null,
|
||||
): IssueToastContext {
|
||||
const issueRefs = resolveIssueQueryRefs(queryClient, companyId, issueId, details);
|
||||
const detailIssue = issueRefs
|
||||
.map((ref) => queryClient.getQueryData<Issue>(queryKeys.issues.detail(ref)))
|
||||
.find((issue): issue is Issue => !!issue);
|
||||
const listIssue = queryClient
|
||||
.getQueryData<Issue[]>(queryKeys.issues.list(companyId))
|
||||
?.find((issue) => issueRefs.some((ref) => issue.id === ref || issue.identifier === ref));
|
||||
const cachedIssue = detailIssue ?? listIssue ?? null;
|
||||
const ref =
|
||||
readString(details?.identifier) ??
|
||||
readString(details?.issueIdentifier) ??
|
||||
cachedIssue?.identifier ??
|
||||
`Issue ${shortId(issueId)}`;
|
||||
const title =
|
||||
readString(details?.title) ??
|
||||
readString(details?.issueTitle) ??
|
||||
cachedIssue?.title ??
|
||||
null;
|
||||
return {
|
||||
ref,
|
||||
title,
|
||||
label: title ? `${ref} - ${truncate(title, 72)}` : ref,
|
||||
href: `/issues/${cachedIssue?.identifier ?? issueId}`,
|
||||
};
|
||||
}
|
||||
|
||||
function isPageForegrounded(): boolean {
|
||||
if (typeof document === "undefined") return false;
|
||||
if (document.visibilityState !== "visible") return false;
|
||||
if (typeof document.hasFocus === "function" && !document.hasFocus()) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveVisibleIssueRouteContext(
|
||||
queryClient: QueryClient,
|
||||
pathname: string,
|
||||
options?: VisibleRouteOptions,
|
||||
): VisibleIssueRouteContext | null {
|
||||
const isForegrounded = options?.isForegrounded ?? isPageForegrounded();
|
||||
if (!isForegrounded) return null;
|
||||
|
||||
const relativePath = toCompanyRelativePath(pathname);
|
||||
const segments = relativePath.split("/").filter(Boolean);
|
||||
if (segments[0] !== "issues" || !segments[1]) return null;
|
||||
|
||||
const issueRef = decodeURIComponent(segments[1]);
|
||||
const issue = queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueRef)) ?? null;
|
||||
const issueRefs = new Set<string>([issueRef]);
|
||||
if (issue?.id) issueRefs.add(issue.id);
|
||||
if (issue?.identifier) issueRefs.add(issue.identifier);
|
||||
|
||||
const runIds = new Set<string>();
|
||||
const activeRun = queryClient.getQueryData<ActiveRunForIssue | null>(queryKeys.issues.activeRun(issueRef));
|
||||
const liveRuns = queryClient.getQueryData<LiveRunForIssue[]>(queryKeys.issues.liveRuns(issueRef)) ?? [];
|
||||
const linkedRuns = queryClient.getQueryData<RunForIssue[]>(queryKeys.issues.runs(issueRef)) ?? [];
|
||||
|
||||
if (activeRun?.id) runIds.add(activeRun.id);
|
||||
for (const run of liveRuns) {
|
||||
if (run.id) runIds.add(run.id);
|
||||
}
|
||||
for (const run of linkedRuns) {
|
||||
if (run.runId) runIds.add(run.runId);
|
||||
}
|
||||
|
||||
return {
|
||||
issueRefs,
|
||||
assigneeAgentId: issue?.assigneeAgentId ?? null,
|
||||
runIds,
|
||||
};
|
||||
}
|
||||
|
||||
function buildIssueRefsForPayload(entityId: string, details: Record<string, unknown> | null): Set<string> {
|
||||
const refs = new Set<string>([entityId]);
|
||||
const identifier = readString(details?.identifier) ?? readString(details?.issueIdentifier);
|
||||
if (identifier) refs.add(identifier);
|
||||
return refs;
|
||||
}
|
||||
|
||||
function overlaps(a: Set<string>, b: Set<string>): boolean {
|
||||
for (const value of a) {
|
||||
if (b.has(value)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function shouldSuppressActivityToastForVisibleIssue(
|
||||
queryClient: QueryClient,
|
||||
pathname: string,
|
||||
payload: Record<string, unknown>,
|
||||
options?: VisibleRouteOptions,
|
||||
): boolean {
|
||||
const entityType = readString(payload.entityType);
|
||||
const entityId = readString(payload.entityId);
|
||||
if (entityType !== "issue" || !entityId) return false;
|
||||
|
||||
const context = resolveVisibleIssueRouteContext(queryClient, pathname, options);
|
||||
if (!context) return false;
|
||||
|
||||
return overlaps(context.issueRefs, buildIssueRefsForPayload(entityId, readRecord(payload.details)));
|
||||
}
|
||||
|
||||
function shouldSuppressRunStatusToastForVisibleIssue(
|
||||
queryClient: QueryClient,
|
||||
pathname: string,
|
||||
payload: Record<string, unknown>,
|
||||
options?: VisibleRouteOptions,
|
||||
): boolean {
|
||||
const context = resolveVisibleIssueRouteContext(queryClient, pathname, options);
|
||||
if (!context) return false;
|
||||
|
||||
const runId = readString(payload.runId);
|
||||
if (runId && context.runIds.has(runId)) return true;
|
||||
|
||||
const agentId = readString(payload.agentId);
|
||||
return !!agentId && !!context.assigneeAgentId && agentId === context.assigneeAgentId;
|
||||
}
|
||||
|
||||
function shouldSuppressAgentStatusToastForVisibleIssue(
|
||||
queryClient: QueryClient,
|
||||
pathname: string,
|
||||
payload: Record<string, unknown>,
|
||||
options?: VisibleRouteOptions,
|
||||
): boolean {
|
||||
const context = resolveVisibleIssueRouteContext(queryClient, pathname, options);
|
||||
if (!context?.assigneeAgentId) return false;
|
||||
|
||||
const agentId = readString(payload.agentId);
|
||||
return !!agentId && agentId === context.assigneeAgentId;
|
||||
}
|
||||
|
||||
const ISSUE_TOAST_ACTIONS = new Set(["issue.created", "issue.updated", "issue.comment_added"]);
|
||||
const AGENT_TOAST_STATUSES = new Set(["running", "error"]);
|
||||
const TERMINAL_RUN_STATUSES = new Set(["succeeded", "failed", "timed_out", "cancelled"]);
|
||||
|
||||
function describeIssueUpdate(details: Record<string, unknown> | null): string | null {
|
||||
if (!details) return null;
|
||||
const changes: string[] = [];
|
||||
if (typeof details.status === "string") changes.push(`status -> ${details.status.replace(/_/g, " ")}`);
|
||||
if (typeof details.priority === "string") changes.push(`priority -> ${details.priority}`);
|
||||
if (typeof details.assigneeAgentId === "string" || typeof details.assigneeUserId === "string") {
|
||||
changes.push("reassigned");
|
||||
} else if (details.assigneeAgentId === null || details.assigneeUserId === null) {
|
||||
changes.push("unassigned");
|
||||
}
|
||||
if (details.reopened === true) {
|
||||
const from = readString(details.reopenedFrom);
|
||||
changes.push(from ? `reopened from ${from.replace(/_/g, " ")}` : "reopened");
|
||||
}
|
||||
if (typeof details.title === "string") changes.push("title changed");
|
||||
if (typeof details.description === "string") changes.push("description changed");
|
||||
if (changes.length > 0) return changes.join(", ");
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildActivityToast(
|
||||
queryClient: QueryClient,
|
||||
companyId: string,
|
||||
payload: Record<string, unknown>,
|
||||
currentActor: { userId: string | null; agentId: string | null },
|
||||
): ToastInput | null {
|
||||
const entityType = readString(payload.entityType);
|
||||
const entityId = readString(payload.entityId);
|
||||
const action = readString(payload.action);
|
||||
const details = readRecord(payload.details);
|
||||
const actorId = readString(payload.actorId);
|
||||
const actorType = readString(payload.actorType);
|
||||
|
||||
if (entityType !== "issue" || !entityId || !action || !ISSUE_TOAST_ACTIONS.has(action)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const issue = resolveIssueToastContext(queryClient, companyId, entityId, details);
|
||||
const actor = resolveActorLabel(queryClient, companyId, actorType, actorId);
|
||||
const isSelfActivity =
|
||||
(actorType === "user" && !!currentActor.userId && actorId === currentActor.userId) ||
|
||||
(actorType === "agent" && !!currentActor.agentId && actorId === currentActor.agentId);
|
||||
if (isSelfActivity) return null;
|
||||
|
||||
if (action === "issue.created") {
|
||||
return {
|
||||
title: `${actor} created ${issue.ref}`,
|
||||
body: issue.title ? truncate(issue.title, 96) : undefined,
|
||||
tone: "success",
|
||||
action: { label: `View ${issue.ref}`, href: issue.href },
|
||||
dedupeKey: `activity:${action}:${entityId}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (action === "issue.updated") {
|
||||
if (readString(details?.source) === "comment") {
|
||||
// Comment-driven updates emit a paired comment event; show one combined toast on the comment event.
|
||||
return null;
|
||||
}
|
||||
const changeDesc = describeIssueUpdate(details);
|
||||
const body = changeDesc
|
||||
? issue.title
|
||||
? `${truncate(issue.title, 64)} - ${changeDesc}`
|
||||
: changeDesc
|
||||
: issue.title
|
||||
? truncate(issue.title, 96)
|
||||
: issue.label;
|
||||
return {
|
||||
title: `${actor} updated ${issue.ref}`,
|
||||
body: truncate(body, 100),
|
||||
tone: "info",
|
||||
action: { label: `View ${issue.ref}`, href: issue.href },
|
||||
dedupeKey: `activity:${action}:${entityId}`,
|
||||
};
|
||||
}
|
||||
|
||||
const commentId = readString(details?.commentId);
|
||||
const bodySnippet = readString(details?.bodySnippet);
|
||||
const reopened = details?.reopened === true;
|
||||
const updated = details?.updated === true;
|
||||
const reopenedFrom = readString(details?.reopenedFrom);
|
||||
const reopenedLabel = reopened
|
||||
? reopenedFrom
|
||||
? `reopened from ${reopenedFrom.replace(/_/g, " ")}`
|
||||
: "reopened"
|
||||
: null;
|
||||
const title = reopened
|
||||
? `${actor} reopened and commented on ${issue.ref}`
|
||||
: updated
|
||||
? `${actor} commented and updated ${issue.ref}`
|
||||
: `${actor} commented on ${issue.ref}`;
|
||||
const body = bodySnippet
|
||||
? reopenedLabel
|
||||
? `${reopenedLabel} - ${bodySnippet.replace(/^#+\s*/m, "").replace(/\n/g, " ")}`
|
||||
: bodySnippet.replace(/^#+\s*/m, "").replace(/\n/g, " ")
|
||||
: reopenedLabel
|
||||
? issue.title
|
||||
? `${reopenedLabel} - ${issue.title}`
|
||||
: reopenedLabel
|
||||
: issue.title ?? undefined;
|
||||
return {
|
||||
title,
|
||||
body: body ? truncate(body, 96) : undefined,
|
||||
tone: "info",
|
||||
action: { label: `View ${issue.ref}`, href: issue.href },
|
||||
dedupeKey: `activity:${action}:${entityId}:${commentId ?? "na"}`,
|
||||
};
|
||||
}
|
||||
|
||||
function buildJoinRequestToast(
|
||||
payload: Record<string, unknown>,
|
||||
): ToastInput | null {
|
||||
const entityType = readString(payload.entityType);
|
||||
const action = readString(payload.action);
|
||||
const entityId = readString(payload.entityId);
|
||||
const details = readRecord(payload.details);
|
||||
|
||||
if (entityType !== "join_request" || !action || !entityId) return null;
|
||||
if (action !== "join.requested" && action !== "join.request_replayed") return null;
|
||||
|
||||
const requestType = readString(details?.requestType);
|
||||
const label = requestType === "agent" ? "Agent" : "Someone";
|
||||
|
||||
return {
|
||||
title: `${label} wants to join`,
|
||||
body: "A new join request is waiting for approval.",
|
||||
tone: "info",
|
||||
action: { label: "View inbox", href: "/inbox/unread" },
|
||||
dedupeKey: `join-request:${entityId}`,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAgentStatusToast(
|
||||
payload: Record<string, unknown>,
|
||||
nameOf: (id: string) => string | null,
|
||||
queryClient: QueryClient,
|
||||
companyId: string,
|
||||
): ToastInput | null {
|
||||
const agentId = readString(payload.agentId);
|
||||
const status = readString(payload.status);
|
||||
if (!agentId || !status || !AGENT_TOAST_STATUSES.has(status)) return null;
|
||||
|
||||
const tone = status === "error" ? "error" : "info";
|
||||
const name = nameOf(agentId) ?? `Agent ${shortId(agentId)}`;
|
||||
const title =
|
||||
status === "running"
|
||||
? `${name} started`
|
||||
: `${name} errored`;
|
||||
|
||||
const agents = queryClient.getQueryData<Agent[]>(queryKeys.agents.list(companyId));
|
||||
const agent = agents?.find((a) => a.id === agentId);
|
||||
const body = agent?.title ?? undefined;
|
||||
|
||||
return {
|
||||
title,
|
||||
body,
|
||||
tone,
|
||||
action: { label: "View agent", href: `/agents/${agentId}` },
|
||||
dedupeKey: `agent-status:${agentId}:${status}`,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRunStatusToast(
|
||||
payload: Record<string, unknown>,
|
||||
nameOf: (id: string) => string | null,
|
||||
): ToastInput | null {
|
||||
const runId = readString(payload.runId);
|
||||
const agentId = readString(payload.agentId);
|
||||
const status = readString(payload.status);
|
||||
if (!runId || !agentId || !status || !TERMINAL_RUN_STATUSES.has(status)) return null;
|
||||
|
||||
const error = readString(payload.error);
|
||||
const triggerDetail = readString(payload.triggerDetail);
|
||||
const name = nameOf(agentId) ?? `Agent ${shortId(agentId)}`;
|
||||
const tone = status === "succeeded" ? "success" : status === "cancelled" ? "warn" : "error";
|
||||
const statusLabel =
|
||||
status === "succeeded" ? "succeeded"
|
||||
: status === "failed" ? "failed"
|
||||
: status === "timed_out" ? "timed out"
|
||||
: "cancelled";
|
||||
const title = `${name} run ${statusLabel}`;
|
||||
|
||||
let body: string | undefined;
|
||||
if (error) {
|
||||
body = truncate(error, 100);
|
||||
} else if (triggerDetail) {
|
||||
body = `Trigger: ${triggerDetail}`;
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
body,
|
||||
tone,
|
||||
ttlMs: status === "succeeded" ? 5000 : 7000,
|
||||
action: { label: "View run", href: `/agents/${agentId}/runs/${runId}` },
|
||||
dedupeKey: `run-status:${runId}:${status}`,
|
||||
};
|
||||
}
|
||||
|
||||
function invalidateHeartbeatQueries(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
companyId: string,
|
||||
payload: Record<string, unknown>,
|
||||
) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.liveRuns(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.costs(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(companyId) });
|
||||
|
||||
const agentId = readString(payload.agentId);
|
||||
if (agentId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(companyId, agentId) });
|
||||
}
|
||||
}
|
||||
|
||||
function invalidateActivityQueries(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
companyId: string,
|
||||
payload: Record<string, unknown>,
|
||||
) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.activity(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(companyId) });
|
||||
|
||||
const entityType = readString(payload.entityType);
|
||||
const entityId = readString(payload.entityId);
|
||||
|
||||
if (entityType === "issue") {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listTouchedByMe(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listUnreadTouchedByMe(companyId) });
|
||||
if (entityId) {
|
||||
const details = readRecord(payload.details);
|
||||
const issueRefs = resolveIssueQueryRefs(queryClient, companyId, entityId, details);
|
||||
for (const ref of issueRefs) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(ref) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(ref) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(ref) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(ref) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.documents(ref) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.attachments(ref) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.approvals(ref) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(ref) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(ref) });
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (entityType === "agent") {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.org(companyId) });
|
||||
if (entityId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(entityId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(companyId, entityId) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (entityType === "project") {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.list(companyId) });
|
||||
if (entityId) queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(entityId) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (entityType === "goal") {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.goals.list(companyId) });
|
||||
if (entityId) queryClient.invalidateQueries({ queryKey: queryKeys.goals.detail(entityId) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (entityType === "approval") {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(companyId) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (entityType === "join_request") {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.access.joinRequests(companyId) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (entityType === "cost_event") {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.costs(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.usageByProvider(companyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.usageWindowSpend(companyId) });
|
||||
// usageQuotaWindows is intentionally excluded: quota windows come from external provider
|
||||
// apis on a 5-minute poll and do not change in response to cost events logged by agents
|
||||
return;
|
||||
}
|
||||
|
||||
if (entityType === "routine" || entityType === "routine_trigger" || entityType === "routine_run") {
|
||||
queryClient.invalidateQueries({ queryKey: ["routines"] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (entityType === "company") {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
||||
}
|
||||
}
|
||||
|
||||
interface ToastGate {
|
||||
cooldownHits: Map<string, number[]>;
|
||||
suppressUntil: number;
|
||||
}
|
||||
|
||||
function shouldSuppressToast(gate: ToastGate, category: string): boolean {
|
||||
const now = Date.now();
|
||||
if (now < gate.suppressUntil) return true;
|
||||
|
||||
const hits = gate.cooldownHits.get(category);
|
||||
if (!hits) return false;
|
||||
|
||||
const recent = hits.filter((t) => now - t < TOAST_COOLDOWN_WINDOW_MS);
|
||||
gate.cooldownHits.set(category, recent);
|
||||
return recent.length >= TOAST_COOLDOWN_MAX;
|
||||
}
|
||||
|
||||
function recordToastHit(gate: ToastGate, category: string) {
|
||||
const now = Date.now();
|
||||
const hits = gate.cooldownHits.get(category) ?? [];
|
||||
hits.push(now);
|
||||
gate.cooldownHits.set(category, hits);
|
||||
}
|
||||
|
||||
function gatedPushToast(
|
||||
gate: ToastGate,
|
||||
pushToast: (toast: ToastInput) => string | null,
|
||||
category: string,
|
||||
toast: ToastInput,
|
||||
) {
|
||||
if (shouldSuppressToast(gate, category)) return;
|
||||
const id = pushToast(toast);
|
||||
if (id !== null) recordToastHit(gate, category);
|
||||
}
|
||||
|
||||
function handleLiveEvent(
|
||||
queryClient: QueryClient,
|
||||
expectedCompanyId: string,
|
||||
pathname: string,
|
||||
event: LiveEvent,
|
||||
pushToast: (toast: ToastInput) => string | null,
|
||||
gate: ToastGate,
|
||||
currentActor: { userId: string | null; agentId: string | null },
|
||||
) {
|
||||
if (event.companyId !== expectedCompanyId) return;
|
||||
|
||||
const nameOf = (id: string) => resolveAgentName(queryClient, expectedCompanyId, id);
|
||||
const payload = event.payload ?? {};
|
||||
if (event.type === "heartbeat.run.log") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "heartbeat.run.queued" || event.type === "heartbeat.run.status") {
|
||||
invalidateHeartbeatQueries(queryClient, expectedCompanyId, payload);
|
||||
if (event.type === "heartbeat.run.status") {
|
||||
const toast = buildRunStatusToast(payload, nameOf);
|
||||
if (
|
||||
toast &&
|
||||
!shouldSuppressRunStatusToastForVisibleIssue(queryClient, pathname, payload)
|
||||
) {
|
||||
gatedPushToast(gate, pushToast, "run-status", toast);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "heartbeat.run.event") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "agent.status") {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(expectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(expectedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.org(expectedCompanyId) });
|
||||
const agentId = readString(payload.agentId);
|
||||
if (agentId) queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId) });
|
||||
const toast = buildAgentStatusToast(payload, nameOf, queryClient, expectedCompanyId);
|
||||
if (
|
||||
toast &&
|
||||
!shouldSuppressAgentStatusToastForVisibleIssue(queryClient, pathname, payload)
|
||||
) {
|
||||
gatedPushToast(gate, pushToast, "agent-status", toast);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "activity.logged") {
|
||||
invalidateActivityQueries(queryClient, expectedCompanyId, payload);
|
||||
const action = readString(payload.action);
|
||||
const toast =
|
||||
buildActivityToast(queryClient, expectedCompanyId, payload, currentActor) ??
|
||||
buildJoinRequestToast(payload);
|
||||
if (
|
||||
toast &&
|
||||
!shouldSuppressActivityToastForVisibleIssue(queryClient, pathname, payload)
|
||||
) {
|
||||
gatedPushToast(gate, pushToast, `activity:${action ?? "unknown"}`, toast);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const __liveUpdatesTestUtils = {
|
||||
invalidateActivityQueries,
|
||||
shouldSuppressActivityToastForVisibleIssue,
|
||||
shouldSuppressRunStatusToastForVisibleIssue,
|
||||
shouldSuppressAgentStatusToastForVisibleIssue,
|
||||
};
|
||||
|
||||
export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const queryClient = useQueryClient();
|
||||
const { pushToast } = useToast();
|
||||
const location = useLocation();
|
||||
const gateRef = useRef<ToastGate>({ cooldownHits: new Map(), suppressUntil: 0 });
|
||||
const pathnameRef = useRef(location.pathname);
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
retry: false,
|
||||
});
|
||||
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
pathnameRef.current = location.pathname;
|
||||
}, [location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCompanyId) return;
|
||||
|
||||
let closed = false;
|
||||
let reconnectAttempt = 0;
|
||||
let reconnectTimer: number | null = null;
|
||||
let socket: WebSocket | null = null;
|
||||
|
||||
const clearReconnect = () => {
|
||||
if (reconnectTimer !== null) {
|
||||
window.clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (closed) return;
|
||||
reconnectAttempt += 1;
|
||||
const delayMs = Math.min(15000, 1000 * 2 ** Math.min(reconnectAttempt - 1, 4));
|
||||
reconnectTimer = window.setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
connect();
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
if (closed) return;
|
||||
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(selectedCompanyId)}/events/ws`;
|
||||
socket = new WebSocket(url);
|
||||
|
||||
socket.onopen = () => {
|
||||
if (reconnectAttempt > 0) {
|
||||
gateRef.current.suppressUntil = Date.now() + RECONNECT_SUPPRESS_MS;
|
||||
}
|
||||
reconnectAttempt = 0;
|
||||
};
|
||||
|
||||
socket.onmessage = (message) => {
|
||||
const raw = typeof message.data === "string" ? message.data : "";
|
||||
if (!raw) return;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as LiveEvent;
|
||||
handleLiveEvent(queryClient, selectedCompanyId, pathnameRef.current, parsed, pushToast, gateRef.current, {
|
||||
userId: currentUserId,
|
||||
agentId: null,
|
||||
});
|
||||
} catch {
|
||||
// Ignore non-JSON payloads.
|
||||
}
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
socket?.close();
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
if (closed) return;
|
||||
scheduleReconnect();
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
closed = true;
|
||||
clearReconnect();
|
||||
if (socket) {
|
||||
socket.onopen = null;
|
||||
socket.onmessage = null;
|
||||
socket.onerror = null;
|
||||
socket.onclose = null;
|
||||
socket.close(1000, "provider_unmount");
|
||||
}
|
||||
};
|
||||
}, [queryClient, selectedCompanyId, pushToast, currentUserId]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
||||
|
||||
const STORAGE_KEY = "paperclip:panel-visible";
|
||||
|
||||
interface PanelContextValue {
|
||||
panelContent: ReactNode | null;
|
||||
panelVisible: boolean;
|
||||
openPanel: (content: ReactNode) => void;
|
||||
closePanel: () => void;
|
||||
setPanelVisible: (visible: boolean) => void;
|
||||
togglePanelVisible: () => void;
|
||||
}
|
||||
|
||||
const PanelContext = createContext<PanelContextValue | null>(null);
|
||||
|
||||
function readPreference(): boolean {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw === null ? true : raw === "true";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function writePreference(visible: boolean) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, String(visible));
|
||||
} catch {
|
||||
// Ignore storage failures.
|
||||
}
|
||||
}
|
||||
|
||||
export function PanelProvider({ children }: { children: ReactNode }) {
|
||||
const [panelContent, setPanelContent] = useState<ReactNode | null>(null);
|
||||
const [panelVisible, setPanelVisibleState] = useState(readPreference);
|
||||
|
||||
const openPanel = useCallback((content: ReactNode) => {
|
||||
setPanelContent(content);
|
||||
}, []);
|
||||
|
||||
const closePanel = useCallback(() => {
|
||||
setPanelContent(null);
|
||||
}, []);
|
||||
|
||||
const setPanelVisible = useCallback((visible: boolean) => {
|
||||
setPanelVisibleState(visible);
|
||||
writePreference(visible);
|
||||
}, []);
|
||||
|
||||
const togglePanelVisible = useCallback(() => {
|
||||
setPanelVisibleState((prev) => {
|
||||
const next = !prev;
|
||||
writePreference(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PanelContext.Provider
|
||||
value={{ panelContent, panelVisible, openPanel, closePanel, setPanelVisible, togglePanelVisible }}
|
||||
>
|
||||
{children}
|
||||
</PanelContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function usePanel() {
|
||||
const ctx = useContext(PanelContext);
|
||||
if (!ctx) {
|
||||
throw new Error("usePanel must be used within PanelProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createContext, useCallback, useContext, useState, useEffect, type ReactNode } from "react";
|
||||
|
||||
interface SidebarContextValue {
|
||||
sidebarOpen: boolean;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
toggleSidebar: () => void;
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
const SidebarContext = createContext<SidebarContextValue | null>(null);
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export function SidebarProvider({ children }: { children: ReactNode }) {
|
||||
const [isMobile, setIsMobile] = useState(() => window.innerWidth < MOBILE_BREAKPOINT);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(() => window.innerWidth >= MOBILE_BREAKPOINT);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
const onChange = (e: MediaQueryListEvent) => {
|
||||
setIsMobile(e.matches);
|
||||
setSidebarOpen(!e.matches);
|
||||
};
|
||||
mql.addEventListener("change", onChange);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
const toggleSidebar = useCallback(() => setSidebarOpen((v) => !v), []);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={{ sidebarOpen, setSidebarOpen, toggleSidebar, isMobile }}>
|
||||
{children}
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSidebar() {
|
||||
const ctx = useContext(SidebarContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useSidebar must be used within SidebarProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
const THEME_STORAGE_KEY = "paperclip.theme";
|
||||
const DARK_THEME_COLOR = "#18181b";
|
||||
const LIGHT_THEME_COLOR = "#ffffff";
|
||||
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
||||
|
||||
function resolveThemeFromDocument(): Theme {
|
||||
if (typeof document === "undefined") return "dark";
|
||||
return document.documentElement.classList.contains("dark") ? "dark" : "light";
|
||||
}
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
if (typeof document === "undefined") return;
|
||||
const isDark = theme === "dark";
|
||||
const root = document.documentElement;
|
||||
root.classList.toggle("dark", isDark);
|
||||
root.style.colorScheme = isDark ? "dark" : "light";
|
||||
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
|
||||
if (themeColorMeta instanceof HTMLMetaElement) {
|
||||
themeColorMeta.setAttribute("content", isDark ? DARK_THEME_COLOR : LIGHT_THEME_COLOR);
|
||||
}
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>(() => resolveThemeFromDocument());
|
||||
|
||||
const setTheme = useCallback((nextTheme: Theme) => {
|
||||
setThemeState(nextTheme);
|
||||
}, []);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setThemeState((current) => (current === "dark" ? "light" : "dark"));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(theme);
|
||||
try {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, theme);
|
||||
} catch {
|
||||
// Ignore local storage write failures in restricted environments.
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
theme,
|
||||
setTheme,
|
||||
toggleTheme,
|
||||
}),
|
||||
[theme, setTheme, toggleTheme],
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={value}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error("useTheme must be used within ThemeProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
export type ToastTone = "info" | "success" | "warn" | "error";
|
||||
|
||||
export interface ToastAction {
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface ToastInput {
|
||||
id?: string;
|
||||
dedupeKey?: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
tone?: ToastTone;
|
||||
ttlMs?: number;
|
||||
action?: ToastAction;
|
||||
}
|
||||
|
||||
export interface ToastItem {
|
||||
id: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
tone: ToastTone;
|
||||
ttlMs: number;
|
||||
action?: ToastAction;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
toasts: ToastItem[];
|
||||
pushToast: (input: ToastInput) => string | null;
|
||||
dismissToast: (id: string) => void;
|
||||
clearToasts: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_TTL_BY_TONE: Record<ToastTone, number> = {
|
||||
info: 4000,
|
||||
success: 3500,
|
||||
warn: 8000,
|
||||
error: 10000,
|
||||
};
|
||||
const MIN_TTL_MS = 1500;
|
||||
const MAX_TTL_MS = 15000;
|
||||
const MAX_TOASTS = 5;
|
||||
const DEDUPE_WINDOW_MS = 3500;
|
||||
const DEDUPE_MAX_AGE_MS = 20000;
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
function normalizeTtl(value: number | undefined, tone: ToastTone) {
|
||||
const fallback = DEFAULT_TTL_BY_TONE[tone];
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
||||
return Math.max(MIN_TTL_MS, Math.min(MAX_TTL_MS, Math.floor(value)));
|
||||
}
|
||||
|
||||
function generateToastId() {
|
||||
return `toast_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
const timersRef = useRef(new Map<string, number>());
|
||||
const dedupeRef = useRef(new Map<string, number>());
|
||||
|
||||
const clearTimer = useCallback((id: string) => {
|
||||
const handle = timersRef.current.get(id);
|
||||
if (handle !== undefined) {
|
||||
window.clearTimeout(handle);
|
||||
timersRef.current.delete(id);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const dismissToast = useCallback(
|
||||
(id: string) => {
|
||||
clearTimer(id);
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||
},
|
||||
[clearTimer],
|
||||
);
|
||||
|
||||
const clearToasts = useCallback(() => {
|
||||
for (const handle of timersRef.current.values()) {
|
||||
window.clearTimeout(handle);
|
||||
}
|
||||
timersRef.current.clear();
|
||||
setToasts([]);
|
||||
}, []);
|
||||
|
||||
const pushToast = useCallback(
|
||||
(input: ToastInput) => {
|
||||
const now = Date.now();
|
||||
const tone = input.tone ?? "info";
|
||||
const ttlMs = normalizeTtl(input.ttlMs, tone);
|
||||
const dedupeKey =
|
||||
input.dedupeKey ?? input.id ?? `${tone}|${input.title}|${input.body ?? ""}|${input.action?.href ?? ""}`;
|
||||
|
||||
for (const [key, ts] of dedupeRef.current.entries()) {
|
||||
if (now - ts > DEDUPE_MAX_AGE_MS) {
|
||||
dedupeRef.current.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
const lastSeen = dedupeRef.current.get(dedupeKey);
|
||||
if (lastSeen && now - lastSeen < DEDUPE_WINDOW_MS) {
|
||||
return null;
|
||||
}
|
||||
dedupeRef.current.set(dedupeKey, now);
|
||||
|
||||
const id = input.id ?? generateToastId();
|
||||
clearTimer(id);
|
||||
|
||||
setToasts((prev) => {
|
||||
const nextToast: ToastItem = {
|
||||
id,
|
||||
title: input.title,
|
||||
body: input.body,
|
||||
tone,
|
||||
ttlMs,
|
||||
action: input.action,
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
const withoutCurrent = prev.filter((toast) => toast.id !== id);
|
||||
return [nextToast, ...withoutCurrent].slice(0, MAX_TOASTS);
|
||||
});
|
||||
|
||||
const timeout = window.setTimeout(() => {
|
||||
dismissToast(id);
|
||||
}, ttlMs);
|
||||
timersRef.current.set(id, timeout);
|
||||
return id;
|
||||
},
|
||||
[clearTimer, dismissToast],
|
||||
);
|
||||
|
||||
useEffect(() => () => {
|
||||
for (const handle of timersRef.current.values()) {
|
||||
window.clearTimeout(handle);
|
||||
}
|
||||
timersRef.current.clear();
|
||||
}, []);
|
||||
|
||||
const value = useMemo<ToastContextValue>(
|
||||
() => ({
|
||||
toasts,
|
||||
pushToast,
|
||||
dismissToast,
|
||||
clearToasts,
|
||||
}),
|
||||
[toasts, pushToast, dismissToast, clearToasts],
|
||||
);
|
||||
|
||||
return <ToastContext.Provider value={value}>{children}</ToastContext.Provider>;
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error("useToast must be used within a ToastProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
Reference in new issue
Block a user