spaps-issue-reporting-react 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,69 +1,156 @@
1
1
  # spaps-issue-reporting-react
2
2
 
3
- Shared React issue-reporting UI for SPAPS-powered apps. This package owns the floating bug entrypoint, history popover, report-mode state, and create/edit/reply modal flows while leaving each app in control of eligibility and reportable surface registration.
3
+ Shared React issue-reporting UI for SPAPS-compatible apps.
4
4
 
5
- ## Metadata
6
- - `package_name`: `spaps-issue-reporting-react`
7
- - `latest_version`: `0.1.0`
8
- - `minimum_runtime`: `Node.js >=18.0.0`
9
- - `api_base_url`: `https://api.sweetpotato.dev`
5
+ Examples in this README use placeholder IDs and app labels. Replace them with values from your own product, session model, and API client.
10
6
 
11
- ## Installation
7
+ ## Install
12
8
 
13
9
  ```bash
14
- npm install spaps-issue-reporting-react
10
+ npm install spaps-issue-reporting-react react react-dom @tanstack/react-query
15
11
  ```
16
12
 
17
- Required peers:
13
+ This package targets `Node.js >=18` and React 18+.
18
14
 
19
- ```bash
20
- npm install react react-dom @tanstack/react-query
21
- ```
15
+ ## When It Fits
16
+
17
+ | Need | Package gives you |
18
+ | --- | --- |
19
+ | A visible issue-report entry point | Floating button with open/recent state |
20
+ | A guided reporting flow | Report mode, highlighted sections, create/edit/reply modal |
21
+ | A lightweight integration contract | Any client that exposes `issueReporting.*` methods |
22
+ | App-level control | Eligibility, reporter identity, scope, and copy stay in your app |
22
23
 
23
- ## Usage
24
+ ## Quick Start
24
25
 
25
26
  ```tsx
27
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
26
28
  import {
27
29
  FloatingIssueReportButton,
28
30
  IssueReportingProvider,
29
- useReportMode,
31
+ ReportableSection,
30
32
  } from "spaps-issue-reporting-react";
31
- import { spapsClient } from "./spapsClient";
33
+ import { spaps } from "./spapsClient";
32
34
 
33
- function AppShell() {
35
+ const queryClient = new QueryClient();
36
+
37
+ export function AppShell() {
34
38
  return (
35
- <IssueReportingProvider
36
- client={spapsClient}
37
- isEligible={true}
38
- reporterRoleHint="practitioner"
39
- >
40
- <Routes />
41
- <FloatingIssueReportButton />
42
- </IssueReportingProvider>
39
+ <QueryClientProvider client={queryClient}>
40
+ <IssueReportingProvider
41
+ client={spaps}
42
+ isEligible={true}
43
+ principalId="user_123"
44
+ reporterRoleHint="staff"
45
+ allowTenantScope
46
+ defaultScope="mine"
47
+ >
48
+ <ReportableSection
49
+ reportableName={{
50
+ componentKey: "patient_chart",
51
+ componentLabel: "Patient Chart",
52
+ metadata: { area: "overview" },
53
+ }}
54
+ className="rounded-xl"
55
+ >
56
+ <PatientChart />
57
+ </ReportableSection>
58
+
59
+ <FloatingIssueReportButton />
60
+ </IssueReportingProvider>
61
+ </QueryClientProvider>
43
62
  );
44
63
  }
64
+ ```
45
65
 
46
- function ReportableCard() {
47
- const reportMode = useReportMode();
66
+ ## What Your App Still Owns
48
67
 
49
- return (
50
- <section
51
- className={reportMode?.isReportMode ? "ring-2 ring-amber-400" : undefined}
52
- onClick={() => reportMode?.selectPanel("Protocol Widget")}
53
- >
54
- ...
55
- </section>
56
- );
57
- }
68
+ - A client with `issueReporting.getStatus`, `list`, `get`, `create`, `update`, and `reply`.
69
+ - Eligibility rules such as feature flags, account state, or role checks.
70
+ - The current principal ID and optional role hint passed into the provider.
71
+ - Whether users can switch between `mine` and `tenant` queue scope.
72
+ - Styling integration if your build strips package utility classes.
73
+ - Any app-specific copy overrides.
74
+
75
+ ## Exported Surface
76
+
77
+ | Export | Purpose |
78
+ | --- | --- |
79
+ | `IssueReportingProvider` | Owns queries, modal state, copy, scope, and report-mode behavior |
80
+ | `FloatingIssueReportButton` | Renders the floating entry point, popover, and modal |
81
+ | `ReportableSection` | Marks a region as selectable when report mode is active |
82
+ | `useIssueReporting` | Full provider state, including `enterReportMode()` |
83
+ | `useIssueReportingStatus` | Query helper for summary state |
84
+ | `useIssueReportingHistory` | Query helper for filtered history lists |
85
+ | `useIssueReportingMutations` | Mutation helpers for create, update, and reply flows |
86
+ | `useReportMode` | Low-level selection state for custom wrappers |
87
+
88
+ ## Styling Notes
89
+
90
+ - The package ships utility-class based markup for the button, popover, modal, and report-mode highlights.
91
+ - If your CSS build only scans local source files, include this package in the scan path or safelist the relevant classes.
92
+ - `FloatingIssueReportButton` accepts `className` and `positionClassName` for placement tweaks.
93
+ - Provider copy can be overridden with the `copy` prop.
94
+
95
+ ## Development
96
+
97
+ ```bash
98
+ cd packages/issue-reporting-react
99
+ npm ci
100
+ npm run build
101
+ npm test
58
102
  ```
59
103
 
60
- ## What The App Owns
104
+ ## Troubleshooting
105
+
106
+ ### The button renders but the modal never opens
107
+
108
+ Make sure `FloatingIssueReportButton` is rendered inside `IssueReportingProvider` and that `isEligible` is `true`.
109
+
110
+ ### Report mode turns on, but sections do not respond
111
+
112
+ Wrap the target UI in `ReportableSection`, or call `useIssueReporting().selectPanel(...)` from a custom control.
113
+
114
+ ### Tenant scope never appears
115
+
116
+ Set `allowTenantScope={true}`. The provider defaults to `mine`.
117
+
118
+ ### Styles look unformatted
119
+
120
+ Include the package in your Tailwind or CSS-content scan, or override the rendered classes in your host app.
121
+
122
+ ## Limitations
123
+
124
+ - This package assumes React Query is already part of the host app.
125
+ - It focuses on the issue-reporting UI flow; queue policy, triage rules, and backend delivery stay outside the package.
126
+ - Deep visual changes may require wrapping the exported components rather than using props alone.
127
+
128
+ ## FAQ
129
+
130
+ ### Do I need the full SPAPS SDK?
131
+
132
+ No. You only need a client object that implements the `issueReporting` methods expected by the provider.
133
+
134
+ ### Can I launch report mode from my own button?
135
+
136
+ Yes. Use `useIssueReporting().enterReportMode()` inside the provider tree.
137
+
138
+ ### Can I report plain strings instead of structured descriptors?
139
+
140
+ Yes. `reportableName` accepts either a string or a `{ componentKey, componentLabel, ... }` object.
141
+
142
+ ### Can I hide the feature for some accounts?
143
+
144
+ Yes. Drive that from `isEligible`.
145
+
146
+ ### Does this package manage tenant permissions?
147
+
148
+ No. It only renders scope choices that your app explicitly allows.
149
+
150
+ ## About Contributions
61
151
 
62
- - Local eligibility logic such as `practitioner` vs patient feature flags.
63
- - Reportable target registration via `useReportMode`, `ReportableSection`, or equivalent wrappers.
64
- - Tailwind content configuration so package classes are included in the host build.
65
- - Any app-specific copy overrides passed through the provider.
152
+ > *About Contributions:* Please don't take this the wrong way, but I do not accept outside contributions for any of my projects. I simply don't have the mental bandwidth to review anything, and it's my name on the thing, so I'm responsible for any problems it causes; thus, the risk-reward is highly asymmetric from my perspective. I'd also have to worry about other "stakeholders," which seems unwise for tools I mostly make for myself for free. Feel free to submit issues, and even PRs if you want to illustrate a proposed fix, but know I won't merge them directly. Instead, I'll have Claude or Codex review submissions via `gh` and independently decide whether and how to address them. Bug reports in particular are welcome. Sorry if this offends, but I want to avoid wasted time and hurt feelings. I understand this isn't in sync with the prevailing open-source ethos that seeks community contributions, but it's the only way I can move at this velocity and keep my sanity.
66
153
 
67
- ## Related Docs
154
+ ## License
68
155
 
69
- - [CHANGELOG.md](./CHANGELOG.md)
156
+ `UNLICENSED`
package/dist/index.d.mts CHANGED
@@ -2,18 +2,21 @@ import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import * as React from 'react';
3
3
  import React__default, { ReactNode } from 'react';
4
4
  import * as spaps_types from 'spaps-types';
5
- import { IssueReportStatusResult, IssueReportStatus, IssueReportListResult, IssueReport, CreateIssueReportRequest, UpdateIssueReportRequest, ReplyIssueReportRequest } from 'spaps-types';
6
- export { CreateIssueReportRequest, IssueReport, IssueReportListResult, IssueReportStatus, IssueReportStatusResult, ReplyIssueReportRequest, UpdateIssueReportRequest } from 'spaps-types';
5
+ import { IssueReportScope, IssueReportStatusResult, IssueReportStatus, IssueReportListResult, IssueReport, CreateIssueReportRequest, UpdateIssueReportRequest, ReplyIssueReportRequest } from 'spaps-types';
6
+ export { CreateIssueReportRequest, IssueReport, IssueReportListResult, IssueReportScope, IssueReportStatus, IssueReportStatusResult, ReplyIssueReportRequest, UpdateIssueReportRequest } from 'spaps-types';
7
7
  import * as _tanstack_query_core from '@tanstack/query-core';
8
8
  import * as _tanstack_react_query from '@tanstack/react-query';
9
9
 
10
10
  interface IssueReportingClient {
11
11
  issueReporting: {
12
- getStatus: () => Promise<IssueReportStatusResult>;
12
+ getStatus: (params?: {
13
+ scope?: IssueReportScope;
14
+ }) => Promise<IssueReportStatusResult>;
13
15
  list: (params?: {
14
16
  status?: IssueReportStatus;
15
17
  limit?: number;
16
18
  offset?: number;
19
+ scope?: IssueReportScope;
17
20
  }) => Promise<IssueReportListResult>;
18
21
  get: (issueReportId: string) => Promise<IssueReport>;
19
22
  create: (payload: CreateIssueReportRequest) => Promise<IssueReport>;
@@ -28,20 +31,24 @@ interface ReportableTargetDescriptor {
28
31
  metadata?: Record<string, unknown>;
29
32
  }
30
33
  type ReportableInput = string | ReportableTargetDescriptor;
31
- type IssueHistoryFilter = "all" | "open" | "closed";
34
+ type IssueHistoryFilter = "all" | "unresolved" | "resolved";
32
35
  interface IssueReportingCopy {
33
36
  entryAriaLabel: string;
34
37
  popoverTitle: string;
35
38
  reportNewAction: string;
36
39
  filtersAll: string;
37
- filtersOpen: string;
38
- filtersClosed: string;
40
+ filtersUnresolved: string;
41
+ filtersResolved: string;
39
42
  historyHelpText: string;
40
43
  historyLoading: string;
41
44
  historyLoadFailed: string;
45
+ statusLoadFailed: string;
42
46
  emptyAll: string;
43
- emptyOpen: string;
44
- emptyClosed: string;
47
+ emptyUnresolved: string;
48
+ emptyResolved: string;
49
+ scopeLabel: string;
50
+ scopeMine: string;
51
+ scopeTenant: string;
45
52
  reportModeTitle: string;
46
53
  reportModeDescription: string;
47
54
  reportModeCancelAction: string;
@@ -63,12 +70,18 @@ interface IssueReportingCopy {
63
70
  hydrateLoading: string;
64
71
  hydrateFailed: string;
65
72
  retryAction: string;
73
+ originHumanLabel: string;
74
+ originMachineLabel: string;
75
+ machineOriginFallback: string;
66
76
  }
67
77
  interface IssueReportingProviderProps {
68
78
  client: IssueReportingClient;
69
79
  isEligible: boolean;
70
80
  reporterRoleHint?: string;
81
+ principalId?: string | null;
71
82
  getPageUrl?: () => string;
83
+ defaultScope?: IssueReportScope;
84
+ allowTenantScope?: boolean;
72
85
  copy?: Partial<IssueReportingCopy>;
73
86
  children: ReactNode;
74
87
  }
@@ -106,6 +119,7 @@ type IssueReportingContextValue = {
106
119
  client: IssueReportingProviderProps["client"];
107
120
  isEligible: boolean;
108
121
  reporterRoleHint?: string;
122
+ principalId: string | null;
109
123
  copy: IssueReportingCopy;
110
124
  isReportMode: boolean;
111
125
  enterReportMode: () => void;
@@ -118,12 +132,15 @@ type IssueReportingContextValue = {
118
132
  closeModal: () => void;
119
133
  openExistingIssueModal: (issueReportId: string, mode: Exclude<ModalMode, "create">) => Promise<void>;
120
134
  retryModalHydration: () => Promise<void>;
135
+ scope: IssueReportScope;
136
+ setScope: (scope: IssueReportScope) => void;
137
+ allowTenantScope: boolean;
121
138
  };
122
139
  declare const defaultIssueReportingCopy: IssueReportingCopy;
123
140
  declare const issueReportingKeys: {
124
141
  all: readonly ["spaps-issue-reporting"];
125
- status: () => readonly ["spaps-issue-reporting", "status"];
126
- history: () => readonly ["spaps-issue-reporting", "history"];
142
+ status: (scope: IssueReportScope) => readonly ["spaps-issue-reporting", "status", IssueReportScope];
143
+ history: (scope: IssueReportScope) => readonly ["spaps-issue-reporting", "history", IssueReportScope];
127
144
  detail: (issueReportId: string) => readonly ["spaps-issue-reporting", "detail", string];
128
145
  };
129
146
  declare function isOpenIssueStatus(status: IssueReportStatus): boolean;
@@ -330,7 +347,7 @@ declare function useIssueReportingMutations(): {
330
347
  reporterRoleHint?: string;
331
348
  }, unknown>;
332
349
  };
333
- declare function IssueReportingProvider({ client, isEligible, reporterRoleHint, getPageUrl, copy, children, }: IssueReportingProviderProps): react_jsx_runtime.JSX.Element;
350
+ declare function IssueReportingProvider({ client, isEligible, reporterRoleHint, principalId, getPageUrl, defaultScope, allowTenantScope, copy, children, }: IssueReportingProviderProps): react_jsx_runtime.JSX.Element;
334
351
 
335
352
  interface ReportModeContextValue {
336
353
  isReportMode: boolean;
package/dist/index.d.ts CHANGED
@@ -2,18 +2,21 @@ import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import * as React from 'react';
3
3
  import React__default, { ReactNode } from 'react';
4
4
  import * as spaps_types from 'spaps-types';
5
- import { IssueReportStatusResult, IssueReportStatus, IssueReportListResult, IssueReport, CreateIssueReportRequest, UpdateIssueReportRequest, ReplyIssueReportRequest } from 'spaps-types';
6
- export { CreateIssueReportRequest, IssueReport, IssueReportListResult, IssueReportStatus, IssueReportStatusResult, ReplyIssueReportRequest, UpdateIssueReportRequest } from 'spaps-types';
5
+ import { IssueReportScope, IssueReportStatusResult, IssueReportStatus, IssueReportListResult, IssueReport, CreateIssueReportRequest, UpdateIssueReportRequest, ReplyIssueReportRequest } from 'spaps-types';
6
+ export { CreateIssueReportRequest, IssueReport, IssueReportListResult, IssueReportScope, IssueReportStatus, IssueReportStatusResult, ReplyIssueReportRequest, UpdateIssueReportRequest } from 'spaps-types';
7
7
  import * as _tanstack_query_core from '@tanstack/query-core';
8
8
  import * as _tanstack_react_query from '@tanstack/react-query';
9
9
 
10
10
  interface IssueReportingClient {
11
11
  issueReporting: {
12
- getStatus: () => Promise<IssueReportStatusResult>;
12
+ getStatus: (params?: {
13
+ scope?: IssueReportScope;
14
+ }) => Promise<IssueReportStatusResult>;
13
15
  list: (params?: {
14
16
  status?: IssueReportStatus;
15
17
  limit?: number;
16
18
  offset?: number;
19
+ scope?: IssueReportScope;
17
20
  }) => Promise<IssueReportListResult>;
18
21
  get: (issueReportId: string) => Promise<IssueReport>;
19
22
  create: (payload: CreateIssueReportRequest) => Promise<IssueReport>;
@@ -28,20 +31,24 @@ interface ReportableTargetDescriptor {
28
31
  metadata?: Record<string, unknown>;
29
32
  }
30
33
  type ReportableInput = string | ReportableTargetDescriptor;
31
- type IssueHistoryFilter = "all" | "open" | "closed";
34
+ type IssueHistoryFilter = "all" | "unresolved" | "resolved";
32
35
  interface IssueReportingCopy {
33
36
  entryAriaLabel: string;
34
37
  popoverTitle: string;
35
38
  reportNewAction: string;
36
39
  filtersAll: string;
37
- filtersOpen: string;
38
- filtersClosed: string;
40
+ filtersUnresolved: string;
41
+ filtersResolved: string;
39
42
  historyHelpText: string;
40
43
  historyLoading: string;
41
44
  historyLoadFailed: string;
45
+ statusLoadFailed: string;
42
46
  emptyAll: string;
43
- emptyOpen: string;
44
- emptyClosed: string;
47
+ emptyUnresolved: string;
48
+ emptyResolved: string;
49
+ scopeLabel: string;
50
+ scopeMine: string;
51
+ scopeTenant: string;
45
52
  reportModeTitle: string;
46
53
  reportModeDescription: string;
47
54
  reportModeCancelAction: string;
@@ -63,12 +70,18 @@ interface IssueReportingCopy {
63
70
  hydrateLoading: string;
64
71
  hydrateFailed: string;
65
72
  retryAction: string;
73
+ originHumanLabel: string;
74
+ originMachineLabel: string;
75
+ machineOriginFallback: string;
66
76
  }
67
77
  interface IssueReportingProviderProps {
68
78
  client: IssueReportingClient;
69
79
  isEligible: boolean;
70
80
  reporterRoleHint?: string;
81
+ principalId?: string | null;
71
82
  getPageUrl?: () => string;
83
+ defaultScope?: IssueReportScope;
84
+ allowTenantScope?: boolean;
72
85
  copy?: Partial<IssueReportingCopy>;
73
86
  children: ReactNode;
74
87
  }
@@ -106,6 +119,7 @@ type IssueReportingContextValue = {
106
119
  client: IssueReportingProviderProps["client"];
107
120
  isEligible: boolean;
108
121
  reporterRoleHint?: string;
122
+ principalId: string | null;
109
123
  copy: IssueReportingCopy;
110
124
  isReportMode: boolean;
111
125
  enterReportMode: () => void;
@@ -118,12 +132,15 @@ type IssueReportingContextValue = {
118
132
  closeModal: () => void;
119
133
  openExistingIssueModal: (issueReportId: string, mode: Exclude<ModalMode, "create">) => Promise<void>;
120
134
  retryModalHydration: () => Promise<void>;
135
+ scope: IssueReportScope;
136
+ setScope: (scope: IssueReportScope) => void;
137
+ allowTenantScope: boolean;
121
138
  };
122
139
  declare const defaultIssueReportingCopy: IssueReportingCopy;
123
140
  declare const issueReportingKeys: {
124
141
  all: readonly ["spaps-issue-reporting"];
125
- status: () => readonly ["spaps-issue-reporting", "status"];
126
- history: () => readonly ["spaps-issue-reporting", "history"];
142
+ status: (scope: IssueReportScope) => readonly ["spaps-issue-reporting", "status", IssueReportScope];
143
+ history: (scope: IssueReportScope) => readonly ["spaps-issue-reporting", "history", IssueReportScope];
127
144
  detail: (issueReportId: string) => readonly ["spaps-issue-reporting", "detail", string];
128
145
  };
129
146
  declare function isOpenIssueStatus(status: IssueReportStatus): boolean;
@@ -330,7 +347,7 @@ declare function useIssueReportingMutations(): {
330
347
  reporterRoleHint?: string;
331
348
  }, unknown>;
332
349
  };
333
- declare function IssueReportingProvider({ client, isEligible, reporterRoleHint, getPageUrl, copy, children, }: IssueReportingProviderProps): react_jsx_runtime.JSX.Element;
350
+ declare function IssueReportingProvider({ client, isEligible, reporterRoleHint, principalId, getPageUrl, defaultScope, allowTenantScope, copy, children, }: IssueReportingProviderProps): react_jsx_runtime.JSX.Element;
334
351
 
335
352
  interface ReportModeContextValue {
336
353
  isReportMode: boolean;
package/dist/index.js CHANGED
@@ -94,14 +94,18 @@ var defaultIssueReportingCopy = {
94
94
  popoverTitle: "Issue Reports",
95
95
  reportNewAction: "Report New Issue",
96
96
  filtersAll: "All",
97
- filtersOpen: "Open",
98
- filtersClosed: "Closed",
97
+ filtersUnresolved: "Unresolved",
98
+ filtersResolved: "Resolved",
99
99
  historyHelpText: "We read every report. If something is broken or could work better, report it here and it goes straight to the people building the app.",
100
100
  historyLoading: "Loading issues...",
101
101
  historyLoadFailed: "Failed to load issues",
102
+ statusLoadFailed: "Failed to load issue status",
102
103
  emptyAll: "No issues reported yet",
103
- emptyOpen: "No open issues",
104
- emptyClosed: "No resolved or ignored issues",
104
+ emptyUnresolved: "No unresolved issues",
105
+ emptyResolved: "No resolved or ignored issues",
106
+ scopeLabel: "Scope",
107
+ scopeMine: "Mine",
108
+ scopeTenant: "Tenant",
105
109
  reportModeTitle: "Report mode is active",
106
110
  reportModeDescription: "Click a highlighted section to capture the broken surface.",
107
111
  reportModeCancelAction: "Cancel",
@@ -122,12 +126,15 @@ var defaultIssueReportingCopy = {
122
126
  replyAction: "Reply",
123
127
  hydrateLoading: "Loading the latest issue details...",
124
128
  hydrateFailed: "Failed to load the latest issue details.",
125
- retryAction: "Retry"
129
+ retryAction: "Retry",
130
+ originHumanLabel: "Human",
131
+ originMachineLabel: "Machine",
132
+ machineOriginFallback: "system"
126
133
  };
127
134
  var issueReportingKeys = {
128
135
  all: ["spaps-issue-reporting"],
129
- status: () => [...issueReportingKeys.all, "status"],
130
- history: () => [...issueReportingKeys.all, "history"],
136
+ status: (scope) => [...issueReportingKeys.all, "status", scope],
137
+ history: (scope) => [...issueReportingKeys.all, "history", scope],
131
138
  detail: (issueReportId) => [...issueReportingKeys.all, "detail", issueReportId]
132
139
  };
133
140
  function resolvePageUrl(getPageUrl) {
@@ -139,6 +146,12 @@ function resolvePageUrl(getPageUrl) {
139
146
  }
140
147
  return `${window.location.pathname}${window.location.search}${window.location.hash}`;
141
148
  }
149
+ function resolveInitialScope(defaultScope, allowTenantScope) {
150
+ if (allowTenantScope && defaultScope === "tenant") {
151
+ return "tenant";
152
+ }
153
+ return "mine";
154
+ }
142
155
  function normalizeTarget(target, getPageUrl) {
143
156
  if (typeof target === "string") {
144
157
  const normalized = target.trim();
@@ -182,10 +195,10 @@ function isClosedIssueStatus(status) {
182
195
  return status === "resolved" || status === "ignored";
183
196
  }
184
197
  function filterIssueReports(issues, filter) {
185
- if (filter === "open") {
198
+ if (filter === "unresolved") {
186
199
  return issues.filter((issue) => isOpenIssueStatus(issue.status));
187
200
  }
188
- if (filter === "closed") {
201
+ if (filter === "resolved") {
189
202
  return issues.filter((issue) => isClosedIssueStatus(issue.status));
190
203
  }
191
204
  return issues;
@@ -251,20 +264,20 @@ function useIssueReporting() {
251
264
  return context;
252
265
  }
253
266
  function useIssueReportingStatus() {
254
- const { client, isEligible } = useIssueReporting();
267
+ const { client, isEligible, scope } = useIssueReporting();
255
268
  return (0, import_react_query.useQuery)({
256
- queryKey: issueReportingKeys.status(),
257
- queryFn: () => client.issueReporting.getStatus(),
269
+ queryKey: issueReportingKeys.status(scope),
270
+ queryFn: () => client.issueReporting.getStatus({ scope }),
258
271
  enabled: isEligible,
259
272
  retry: false,
260
273
  staleTime: 3e4
261
274
  });
262
275
  }
263
276
  function useIssueReportingHistory(filter = "all") {
264
- const { client, isEligible } = useIssueReporting();
277
+ const { client, isEligible, scope } = useIssueReporting();
265
278
  const query = (0, import_react_query.useQuery)({
266
- queryKey: issueReportingKeys.history(),
267
- queryFn: () => client.issueReporting.list({ limit: LIST_LIMIT, offset: 0 }),
279
+ queryKey: issueReportingKeys.history(scope),
280
+ queryFn: () => client.issueReporting.list({ limit: LIST_LIMIT, offset: 0, scope }),
268
281
  enabled: isEligible,
269
282
  retry: false
270
283
  });
@@ -275,7 +288,7 @@ function useIssueReportingHistory(filter = "all") {
275
288
  return {
276
289
  ...query,
277
290
  items,
278
- total: items.length
291
+ total: query.data?.total ?? items.length
279
292
  };
280
293
  }
281
294
  function useIssueReportingMutations() {
@@ -318,7 +331,10 @@ function IssueReportingProvider({
318
331
  client,
319
332
  isEligible,
320
333
  reporterRoleHint,
334
+ principalId,
321
335
  getPageUrl,
336
+ defaultScope,
337
+ allowTenantScope = false,
322
338
  copy,
323
339
  children
324
340
  }) {
@@ -333,6 +349,12 @@ function IssueReportingProvider({
333
349
  const [isReportMode, setIsReportMode] = (0, import_react2.useState)(false);
334
350
  const [isPopoverOpen, setIsPopoverOpen] = (0, import_react2.useState)(false);
335
351
  const [modalState, setModalState] = (0, import_react2.useState)(initialModalState);
352
+ const [scope, setScope] = (0, import_react2.useState)(
353
+ () => resolveInitialScope(defaultScope, allowTenantScope)
354
+ );
355
+ (0, import_react2.useEffect)(() => {
356
+ setScope(resolveInitialScope(defaultScope, allowTenantScope));
357
+ }, [allowTenantScope, defaultScope]);
336
358
  const closePopover = (0, import_react2.useCallback)(() => {
337
359
  setIsPopoverOpen(false);
338
360
  }, []);
@@ -426,6 +448,7 @@ function IssueReportingProvider({
426
448
  client,
427
449
  isEligible,
428
450
  reporterRoleHint,
451
+ principalId: principalId ?? null,
429
452
  copy: mergedCopy,
430
453
  isReportMode,
431
454
  enterReportMode,
@@ -437,9 +460,13 @@ function IssueReportingProvider({
437
460
  modalState,
438
461
  closeModal,
439
462
  openExistingIssueModal,
440
- retryModalHydration
463
+ retryModalHydration,
464
+ scope,
465
+ setScope,
466
+ allowTenantScope
441
467
  }),
442
468
  [
469
+ allowTenantScope,
443
470
  cancelReportMode,
444
471
  client,
445
472
  closeModal,
@@ -452,8 +479,10 @@ function IssueReportingProvider({
452
479
  modalState,
453
480
  openExistingIssueModal,
454
481
  openPopover,
482
+ principalId,
455
483
  reporterRoleHint,
456
484
  retryModalHydration,
485
+ scope,
457
486
  selectPanel
458
487
  ]
459
488
  );
@@ -484,6 +513,47 @@ function formatRelativeTime(timestamp) {
484
513
  addSuffix: true
485
514
  });
486
515
  }
516
+ function resolveErrorMessage(error, fallback) {
517
+ if (error instanceof Error && error.message.trim()) {
518
+ return error.message;
519
+ }
520
+ if (error && typeof error === "object" && "message" in error && typeof error.message === "string" && error.message.trim()) {
521
+ return error.message;
522
+ }
523
+ return fallback;
524
+ }
525
+ function resolveReporterId(issue) {
526
+ return issue.reporter_principal_id ?? issue.reporter_user_id ?? null;
527
+ }
528
+ function resolveReporterType(issue) {
529
+ if (issue.reporter_principal_type) {
530
+ return issue.reporter_principal_type;
531
+ }
532
+ if (issue.machine_principal_id || issue.agent_id) {
533
+ return "machine";
534
+ }
535
+ return "human";
536
+ }
537
+ function getIssueAction(issue, principalId) {
538
+ if (!principalId) {
539
+ return null;
540
+ }
541
+ if (resolveReporterType(issue) !== "human") {
542
+ return null;
543
+ }
544
+ if (resolveReporterId(issue) !== principalId) {
545
+ return null;
546
+ }
547
+ return isClosedIssueStatus(issue.status) ? "reply" : "edit";
548
+ }
549
+ function getIssueOriginText(issue, copy) {
550
+ if (resolveReporterType(issue) === "machine") {
551
+ const provenance = issue.agent_id ?? issue.machine_principal_id ?? issue.reporter_display_name ?? copy.machineOriginFallback;
552
+ return `${copy.originMachineLabel} \xB7 ${provenance}`;
553
+ }
554
+ const humanName = issue.reporter_display_name?.trim();
555
+ return humanName ? `${copy.originHumanLabel} \xB7 ${humanName}` : copy.originHumanLabel;
556
+ }
487
557
  function IssueReportModeBanner() {
488
558
  const { copy } = useIssueReporting();
489
559
  const reportMode = useReportMode();
@@ -509,7 +579,7 @@ function IssueList({
509
579
  onEdit,
510
580
  onReply
511
581
  }) {
512
- const { copy } = useIssueReporting();
582
+ const { copy, principalId } = useIssueReporting();
513
583
  const history = useIssueReportingHistory(filter);
514
584
  if (history.isPending) {
515
585
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "flex items-center gap-2 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-5 text-sm text-slate-600", children: [
@@ -532,19 +602,18 @@ function IssueList({
532
602
  ] });
533
603
  }
534
604
  if (history.items.length === 0) {
535
- const emptyMessage = filter === "open" ? copy.emptyOpen : filter === "closed" ? copy.emptyClosed : copy.emptyAll;
605
+ const emptyMessage = filter === "unresolved" ? copy.emptyUnresolved : filter === "resolved" ? copy.emptyResolved : copy.emptyAll;
536
606
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-4 py-8 text-center text-sm text-slate-500", children: emptyMessage });
537
607
  }
538
608
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "max-h-80 space-y-2 overflow-y-auto pr-1", children: history.items.map((issue) => {
539
- const isClosed = isClosedIssueStatus(issue.status);
540
- const actionLabel = isClosed ? copy.replyAction : copy.editAction;
541
- const onAction = () => isClosed ? onReply(issue.id) : onEdit(issue.id);
609
+ const action = getIssueAction(issue, principalId);
610
+ const onAction = action === "edit" ? () => onEdit(issue.id) : action === "reply" ? () => onReply(issue.id) : null;
542
611
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
543
612
  "div",
544
613
  {
545
614
  className: "rounded-2xl border border-slate-200 bg-white px-3 py-3 shadow-sm",
546
615
  children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "flex items-start gap-3", children: [
547
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "mt-0.5 flex-shrink-0", children: isClosed ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react3.CheckCircle, { className: "h-4 w-4 text-emerald-600", weight: "fill" }) : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react3.Circle, { className: "h-4 w-4 text-rose-600", weight: "fill" }) }),
616
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "mt-0.5 flex-shrink-0", children: isClosedIssueStatus(issue.status) ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react3.CheckCircle, { className: "h-4 w-4 text-emerald-600", weight: "fill" }) : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react3.Circle, { className: "h-4 w-4 text-rose-600", weight: "fill" }) }),
548
617
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "min-w-0 flex-1", children: [
549
618
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "flex items-start justify-between gap-2", children: [
550
619
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "min-w-0", children: [
@@ -560,18 +629,19 @@ function IssueList({
560
629
  children: getIssueStatusBadgeLabel(issue.status)
561
630
  }
562
631
  ),
632
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600", children: getIssueOriginText(issue, copy) }),
563
633
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "text-xs text-slate-400", children: formatRelativeTime(issue.updated_at) })
564
634
  ] })
565
635
  ] }),
566
- /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
636
+ action && onAction ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
567
637
  "button",
568
638
  {
569
639
  type: "button",
570
640
  className: "rounded-full border border-slate-200 px-3 py-1 text-xs font-medium text-slate-700 transition hover:bg-slate-50",
571
641
  onClick: onAction,
572
- children: actionLabel
642
+ children: action === "reply" ? copy.replyAction : copy.editAction
573
643
  }
574
- )
644
+ ) : null
575
645
  ] }),
576
646
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "mt-2 text-sm text-slate-600", children: truncate(issue.note) }),
577
647
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "mt-2 truncate text-xs text-slate-400", children: issue.target.page_url })
@@ -590,7 +660,10 @@ function IssueReportPopover({ children }) {
590
660
  openPopover,
591
661
  closePopover,
592
662
  enterReportMode,
593
- openExistingIssueModal
663
+ openExistingIssueModal,
664
+ scope,
665
+ setScope,
666
+ allowTenantScope
594
667
  } = useIssueReporting();
595
668
  const history = useIssueReportingHistory("all");
596
669
  const status = useIssueReportingStatus();
@@ -598,11 +671,12 @@ function IssueReportPopover({ children }) {
598
671
  const counts = (0, import_react4.useMemo)(
599
672
  () => ({
600
673
  all: allItems.length,
601
- open: filterIssueReports(allItems, "open").length,
602
- closed: filterIssueReports(allItems, "closed").length
674
+ unresolved: filterIssueReports(allItems, "unresolved").length,
675
+ resolved: filterIssueReports(allItems, "resolved").length
603
676
  }),
604
677
  [allItems]
605
678
  );
679
+ const statusSummary = status.data ? `${status.data.open_count} open \xB7 ${status.data.recent_resolved_count} recently resolved` : "Status reflects the active scope.";
606
680
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
607
681
  Popover.Root,
608
682
  {
@@ -621,11 +695,7 @@ function IssueReportPopover({ children }) {
621
695
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "flex items-center justify-between gap-3", children: [
622
696
  /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { children: [
623
697
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("h3", { className: "text-sm font-semibold text-slate-900", children: copy.popoverTitle }),
624
- status.data?.has_open ? /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("p", { className: "mt-1 text-xs text-slate-500", children: [
625
- status.data.open_count,
626
- " open issue",
627
- status.data.open_count === 1 ? "" : "s"
628
- ] }) : null
698
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("p", { className: "mt-1 text-xs text-slate-500", children: statusSummary })
629
699
  ] }),
630
700
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
631
701
  "button",
@@ -640,10 +710,41 @@ function IssueReportPopover({ children }) {
640
710
  }
641
711
  )
642
712
  ] }),
713
+ status.error ? /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "space-y-3 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-4 text-sm text-rose-700", children: [
714
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { children: status.error.message || copy.statusLoadFailed }),
715
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
716
+ "button",
717
+ {
718
+ type: "button",
719
+ className: "rounded-full border border-rose-300 px-3 py-1 font-medium transition hover:bg-rose-100",
720
+ onClick: () => status.refetch(),
721
+ children: copy.retryAction
722
+ }
723
+ )
724
+ ] }) : null,
725
+ allowTenantScope ? /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "space-y-2", children: [
726
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "text-[11px] font-medium uppercase tracking-wide text-slate-500", children: copy.scopeLabel }),
727
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "flex gap-2", children: [
728
+ ["tenant", copy.scopeTenant],
729
+ ["mine", copy.scopeMine]
730
+ ].map(([value, label]) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
731
+ "button",
732
+ {
733
+ type: "button",
734
+ className: cn(
735
+ "rounded-full px-3 py-1.5 text-xs font-medium transition",
736
+ scope === value ? "bg-slate-900 text-white" : "bg-slate-100 text-slate-600 hover:bg-slate-200"
737
+ ),
738
+ onClick: () => setScope(value),
739
+ children: label
740
+ },
741
+ value
742
+ )) })
743
+ ] }) : null,
643
744
  /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "flex gap-2", children: [
644
745
  ["all", copy.filtersAll, counts.all],
645
- ["open", copy.filtersOpen, counts.open],
646
- ["closed", copy.filtersClosed, counts.closed]
746
+ ["unresolved", copy.filtersUnresolved, counts.unresolved],
747
+ ["resolved", copy.filtersResolved, counts.resolved]
647
748
  ].map(([value, label, count]) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
648
749
  "button",
649
750
  {
@@ -731,9 +832,7 @@ function IssueReportModal() {
731
832
  }
732
833
  closeModal();
733
834
  } catch (submissionError) {
734
- setSubmitError(
735
- submissionError instanceof Error ? submissionError.message : "Failed to submit issue report"
736
- );
835
+ setSubmitError(resolveErrorMessage(submissionError, "Failed to submit issue report"));
737
836
  }
738
837
  };
739
838
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Dialog.Root, { open: isOpen, onOpenChange: (open) => !open && closeModal(), children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(Dialog.Portal, { children: [
package/dist/index.mjs CHANGED
@@ -9,13 +9,14 @@ import {
9
9
  X
10
10
  } from "@phosphor-icons/react";
11
11
  import { formatDistanceToNow } from "date-fns";
12
- import { useEffect, useMemo as useMemo2, useState as useState2 } from "react";
12
+ import { useEffect as useEffect2, useMemo as useMemo2, useState as useState2 } from "react";
13
13
 
14
14
  // src/provider.tsx
15
15
  import {
16
16
  createContext as createContext2,
17
17
  useCallback,
18
18
  useContext as useContext2,
19
+ useEffect,
19
20
  useMemo,
20
21
  useState
21
22
  } from "react";
@@ -56,14 +57,18 @@ var defaultIssueReportingCopy = {
56
57
  popoverTitle: "Issue Reports",
57
58
  reportNewAction: "Report New Issue",
58
59
  filtersAll: "All",
59
- filtersOpen: "Open",
60
- filtersClosed: "Closed",
60
+ filtersUnresolved: "Unresolved",
61
+ filtersResolved: "Resolved",
61
62
  historyHelpText: "We read every report. If something is broken or could work better, report it here and it goes straight to the people building the app.",
62
63
  historyLoading: "Loading issues...",
63
64
  historyLoadFailed: "Failed to load issues",
65
+ statusLoadFailed: "Failed to load issue status",
64
66
  emptyAll: "No issues reported yet",
65
- emptyOpen: "No open issues",
66
- emptyClosed: "No resolved or ignored issues",
67
+ emptyUnresolved: "No unresolved issues",
68
+ emptyResolved: "No resolved or ignored issues",
69
+ scopeLabel: "Scope",
70
+ scopeMine: "Mine",
71
+ scopeTenant: "Tenant",
67
72
  reportModeTitle: "Report mode is active",
68
73
  reportModeDescription: "Click a highlighted section to capture the broken surface.",
69
74
  reportModeCancelAction: "Cancel",
@@ -84,12 +89,15 @@ var defaultIssueReportingCopy = {
84
89
  replyAction: "Reply",
85
90
  hydrateLoading: "Loading the latest issue details...",
86
91
  hydrateFailed: "Failed to load the latest issue details.",
87
- retryAction: "Retry"
92
+ retryAction: "Retry",
93
+ originHumanLabel: "Human",
94
+ originMachineLabel: "Machine",
95
+ machineOriginFallback: "system"
88
96
  };
89
97
  var issueReportingKeys = {
90
98
  all: ["spaps-issue-reporting"],
91
- status: () => [...issueReportingKeys.all, "status"],
92
- history: () => [...issueReportingKeys.all, "history"],
99
+ status: (scope) => [...issueReportingKeys.all, "status", scope],
100
+ history: (scope) => [...issueReportingKeys.all, "history", scope],
93
101
  detail: (issueReportId) => [...issueReportingKeys.all, "detail", issueReportId]
94
102
  };
95
103
  function resolvePageUrl(getPageUrl) {
@@ -101,6 +109,12 @@ function resolvePageUrl(getPageUrl) {
101
109
  }
102
110
  return `${window.location.pathname}${window.location.search}${window.location.hash}`;
103
111
  }
112
+ function resolveInitialScope(defaultScope, allowTenantScope) {
113
+ if (allowTenantScope && defaultScope === "tenant") {
114
+ return "tenant";
115
+ }
116
+ return "mine";
117
+ }
104
118
  function normalizeTarget(target, getPageUrl) {
105
119
  if (typeof target === "string") {
106
120
  const normalized = target.trim();
@@ -144,10 +158,10 @@ function isClosedIssueStatus(status) {
144
158
  return status === "resolved" || status === "ignored";
145
159
  }
146
160
  function filterIssueReports(issues, filter) {
147
- if (filter === "open") {
161
+ if (filter === "unresolved") {
148
162
  return issues.filter((issue) => isOpenIssueStatus(issue.status));
149
163
  }
150
- if (filter === "closed") {
164
+ if (filter === "resolved") {
151
165
  return issues.filter((issue) => isClosedIssueStatus(issue.status));
152
166
  }
153
167
  return issues;
@@ -213,20 +227,20 @@ function useIssueReporting() {
213
227
  return context;
214
228
  }
215
229
  function useIssueReportingStatus() {
216
- const { client, isEligible } = useIssueReporting();
230
+ const { client, isEligible, scope } = useIssueReporting();
217
231
  return useQuery({
218
- queryKey: issueReportingKeys.status(),
219
- queryFn: () => client.issueReporting.getStatus(),
232
+ queryKey: issueReportingKeys.status(scope),
233
+ queryFn: () => client.issueReporting.getStatus({ scope }),
220
234
  enabled: isEligible,
221
235
  retry: false,
222
236
  staleTime: 3e4
223
237
  });
224
238
  }
225
239
  function useIssueReportingHistory(filter = "all") {
226
- const { client, isEligible } = useIssueReporting();
240
+ const { client, isEligible, scope } = useIssueReporting();
227
241
  const query = useQuery({
228
- queryKey: issueReportingKeys.history(),
229
- queryFn: () => client.issueReporting.list({ limit: LIST_LIMIT, offset: 0 }),
242
+ queryKey: issueReportingKeys.history(scope),
243
+ queryFn: () => client.issueReporting.list({ limit: LIST_LIMIT, offset: 0, scope }),
230
244
  enabled: isEligible,
231
245
  retry: false
232
246
  });
@@ -237,7 +251,7 @@ function useIssueReportingHistory(filter = "all") {
237
251
  return {
238
252
  ...query,
239
253
  items,
240
- total: items.length
254
+ total: query.data?.total ?? items.length
241
255
  };
242
256
  }
243
257
  function useIssueReportingMutations() {
@@ -280,7 +294,10 @@ function IssueReportingProvider({
280
294
  client,
281
295
  isEligible,
282
296
  reporterRoleHint,
297
+ principalId,
283
298
  getPageUrl,
299
+ defaultScope,
300
+ allowTenantScope = false,
284
301
  copy,
285
302
  children
286
303
  }) {
@@ -295,6 +312,12 @@ function IssueReportingProvider({
295
312
  const [isReportMode, setIsReportMode] = useState(false);
296
313
  const [isPopoverOpen, setIsPopoverOpen] = useState(false);
297
314
  const [modalState, setModalState] = useState(initialModalState);
315
+ const [scope, setScope] = useState(
316
+ () => resolveInitialScope(defaultScope, allowTenantScope)
317
+ );
318
+ useEffect(() => {
319
+ setScope(resolveInitialScope(defaultScope, allowTenantScope));
320
+ }, [allowTenantScope, defaultScope]);
298
321
  const closePopover = useCallback(() => {
299
322
  setIsPopoverOpen(false);
300
323
  }, []);
@@ -388,6 +411,7 @@ function IssueReportingProvider({
388
411
  client,
389
412
  isEligible,
390
413
  reporterRoleHint,
414
+ principalId: principalId ?? null,
391
415
  copy: mergedCopy,
392
416
  isReportMode,
393
417
  enterReportMode,
@@ -399,9 +423,13 @@ function IssueReportingProvider({
399
423
  modalState,
400
424
  closeModal,
401
425
  openExistingIssueModal,
402
- retryModalHydration
426
+ retryModalHydration,
427
+ scope,
428
+ setScope,
429
+ allowTenantScope
403
430
  }),
404
431
  [
432
+ allowTenantScope,
405
433
  cancelReportMode,
406
434
  client,
407
435
  closeModal,
@@ -414,8 +442,10 @@ function IssueReportingProvider({
414
442
  modalState,
415
443
  openExistingIssueModal,
416
444
  openPopover,
445
+ principalId,
417
446
  reporterRoleHint,
418
447
  retryModalHydration,
448
+ scope,
419
449
  selectPanel
420
450
  ]
421
451
  );
@@ -446,6 +476,47 @@ function formatRelativeTime(timestamp) {
446
476
  addSuffix: true
447
477
  });
448
478
  }
479
+ function resolveErrorMessage(error, fallback) {
480
+ if (error instanceof Error && error.message.trim()) {
481
+ return error.message;
482
+ }
483
+ if (error && typeof error === "object" && "message" in error && typeof error.message === "string" && error.message.trim()) {
484
+ return error.message;
485
+ }
486
+ return fallback;
487
+ }
488
+ function resolveReporterId(issue) {
489
+ return issue.reporter_principal_id ?? issue.reporter_user_id ?? null;
490
+ }
491
+ function resolveReporterType(issue) {
492
+ if (issue.reporter_principal_type) {
493
+ return issue.reporter_principal_type;
494
+ }
495
+ if (issue.machine_principal_id || issue.agent_id) {
496
+ return "machine";
497
+ }
498
+ return "human";
499
+ }
500
+ function getIssueAction(issue, principalId) {
501
+ if (!principalId) {
502
+ return null;
503
+ }
504
+ if (resolveReporterType(issue) !== "human") {
505
+ return null;
506
+ }
507
+ if (resolveReporterId(issue) !== principalId) {
508
+ return null;
509
+ }
510
+ return isClosedIssueStatus(issue.status) ? "reply" : "edit";
511
+ }
512
+ function getIssueOriginText(issue, copy) {
513
+ if (resolveReporterType(issue) === "machine") {
514
+ const provenance = issue.agent_id ?? issue.machine_principal_id ?? issue.reporter_display_name ?? copy.machineOriginFallback;
515
+ return `${copy.originMachineLabel} \xB7 ${provenance}`;
516
+ }
517
+ const humanName = issue.reporter_display_name?.trim();
518
+ return humanName ? `${copy.originHumanLabel} \xB7 ${humanName}` : copy.originHumanLabel;
519
+ }
449
520
  function IssueReportModeBanner() {
450
521
  const { copy } = useIssueReporting();
451
522
  const reportMode = useReportMode();
@@ -471,7 +542,7 @@ function IssueList({
471
542
  onEdit,
472
543
  onReply
473
544
  }) {
474
- const { copy } = useIssueReporting();
545
+ const { copy, principalId } = useIssueReporting();
475
546
  const history = useIssueReportingHistory(filter);
476
547
  if (history.isPending) {
477
548
  return /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-5 text-sm text-slate-600", children: [
@@ -494,19 +565,18 @@ function IssueList({
494
565
  ] });
495
566
  }
496
567
  if (history.items.length === 0) {
497
- const emptyMessage = filter === "open" ? copy.emptyOpen : filter === "closed" ? copy.emptyClosed : copy.emptyAll;
568
+ const emptyMessage = filter === "unresolved" ? copy.emptyUnresolved : filter === "resolved" ? copy.emptyResolved : copy.emptyAll;
498
569
  return /* @__PURE__ */ jsx2("div", { className: "rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-4 py-8 text-center text-sm text-slate-500", children: emptyMessage });
499
570
  }
500
571
  return /* @__PURE__ */ jsx2("div", { className: "max-h-80 space-y-2 overflow-y-auto pr-1", children: history.items.map((issue) => {
501
- const isClosed = isClosedIssueStatus(issue.status);
502
- const actionLabel = isClosed ? copy.replyAction : copy.editAction;
503
- const onAction = () => isClosed ? onReply(issue.id) : onEdit(issue.id);
572
+ const action = getIssueAction(issue, principalId);
573
+ const onAction = action === "edit" ? () => onEdit(issue.id) : action === "reply" ? () => onReply(issue.id) : null;
504
574
  return /* @__PURE__ */ jsx2(
505
575
  "div",
506
576
  {
507
577
  className: "rounded-2xl border border-slate-200 bg-white px-3 py-3 shadow-sm",
508
578
  children: /* @__PURE__ */ jsxs("div", { className: "flex items-start gap-3", children: [
509
- /* @__PURE__ */ jsx2("div", { className: "mt-0.5 flex-shrink-0", children: isClosed ? /* @__PURE__ */ jsx2(CheckCircle, { className: "h-4 w-4 text-emerald-600", weight: "fill" }) : /* @__PURE__ */ jsx2(Circle, { className: "h-4 w-4 text-rose-600", weight: "fill" }) }),
579
+ /* @__PURE__ */ jsx2("div", { className: "mt-0.5 flex-shrink-0", children: isClosedIssueStatus(issue.status) ? /* @__PURE__ */ jsx2(CheckCircle, { className: "h-4 w-4 text-emerald-600", weight: "fill" }) : /* @__PURE__ */ jsx2(Circle, { className: "h-4 w-4 text-rose-600", weight: "fill" }) }),
510
580
  /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
511
581
  /* @__PURE__ */ jsxs("div", { className: "flex items-start justify-between gap-2", children: [
512
582
  /* @__PURE__ */ jsxs("div", { className: "min-w-0", children: [
@@ -522,18 +592,19 @@ function IssueList({
522
592
  children: getIssueStatusBadgeLabel(issue.status)
523
593
  }
524
594
  ),
595
+ /* @__PURE__ */ jsx2("span", { className: "rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600", children: getIssueOriginText(issue, copy) }),
525
596
  /* @__PURE__ */ jsx2("span", { className: "text-xs text-slate-400", children: formatRelativeTime(issue.updated_at) })
526
597
  ] })
527
598
  ] }),
528
- /* @__PURE__ */ jsx2(
599
+ action && onAction ? /* @__PURE__ */ jsx2(
529
600
  "button",
530
601
  {
531
602
  type: "button",
532
603
  className: "rounded-full border border-slate-200 px-3 py-1 text-xs font-medium text-slate-700 transition hover:bg-slate-50",
533
604
  onClick: onAction,
534
- children: actionLabel
605
+ children: action === "reply" ? copy.replyAction : copy.editAction
535
606
  }
536
- )
607
+ ) : null
537
608
  ] }),
538
609
  /* @__PURE__ */ jsx2("p", { className: "mt-2 text-sm text-slate-600", children: truncate(issue.note) }),
539
610
  /* @__PURE__ */ jsx2("div", { className: "mt-2 truncate text-xs text-slate-400", children: issue.target.page_url })
@@ -552,7 +623,10 @@ function IssueReportPopover({ children }) {
552
623
  openPopover,
553
624
  closePopover,
554
625
  enterReportMode,
555
- openExistingIssueModal
626
+ openExistingIssueModal,
627
+ scope,
628
+ setScope,
629
+ allowTenantScope
556
630
  } = useIssueReporting();
557
631
  const history = useIssueReportingHistory("all");
558
632
  const status = useIssueReportingStatus();
@@ -560,11 +634,12 @@ function IssueReportPopover({ children }) {
560
634
  const counts = useMemo2(
561
635
  () => ({
562
636
  all: allItems.length,
563
- open: filterIssueReports(allItems, "open").length,
564
- closed: filterIssueReports(allItems, "closed").length
637
+ unresolved: filterIssueReports(allItems, "unresolved").length,
638
+ resolved: filterIssueReports(allItems, "resolved").length
565
639
  }),
566
640
  [allItems]
567
641
  );
642
+ const statusSummary = status.data ? `${status.data.open_count} open \xB7 ${status.data.recent_resolved_count} recently resolved` : "Status reflects the active scope.";
568
643
  return /* @__PURE__ */ jsxs(
569
644
  Popover.Root,
570
645
  {
@@ -583,11 +658,7 @@ function IssueReportPopover({ children }) {
583
658
  /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-3", children: [
584
659
  /* @__PURE__ */ jsxs("div", { children: [
585
660
  /* @__PURE__ */ jsx2("h3", { className: "text-sm font-semibold text-slate-900", children: copy.popoverTitle }),
586
- status.data?.has_open ? /* @__PURE__ */ jsxs("p", { className: "mt-1 text-xs text-slate-500", children: [
587
- status.data.open_count,
588
- " open issue",
589
- status.data.open_count === 1 ? "" : "s"
590
- ] }) : null
661
+ /* @__PURE__ */ jsx2("p", { className: "mt-1 text-xs text-slate-500", children: statusSummary })
591
662
  ] }),
592
663
  /* @__PURE__ */ jsx2(
593
664
  "button",
@@ -602,10 +673,41 @@ function IssueReportPopover({ children }) {
602
673
  }
603
674
  )
604
675
  ] }),
676
+ status.error ? /* @__PURE__ */ jsxs("div", { className: "space-y-3 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-4 text-sm text-rose-700", children: [
677
+ /* @__PURE__ */ jsx2("div", { children: status.error.message || copy.statusLoadFailed }),
678
+ /* @__PURE__ */ jsx2(
679
+ "button",
680
+ {
681
+ type: "button",
682
+ className: "rounded-full border border-rose-300 px-3 py-1 font-medium transition hover:bg-rose-100",
683
+ onClick: () => status.refetch(),
684
+ children: copy.retryAction
685
+ }
686
+ )
687
+ ] }) : null,
688
+ allowTenantScope ? /* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
689
+ /* @__PURE__ */ jsx2("div", { className: "text-[11px] font-medium uppercase tracking-wide text-slate-500", children: copy.scopeLabel }),
690
+ /* @__PURE__ */ jsx2("div", { className: "flex gap-2", children: [
691
+ ["tenant", copy.scopeTenant],
692
+ ["mine", copy.scopeMine]
693
+ ].map(([value, label]) => /* @__PURE__ */ jsx2(
694
+ "button",
695
+ {
696
+ type: "button",
697
+ className: cn(
698
+ "rounded-full px-3 py-1.5 text-xs font-medium transition",
699
+ scope === value ? "bg-slate-900 text-white" : "bg-slate-100 text-slate-600 hover:bg-slate-200"
700
+ ),
701
+ onClick: () => setScope(value),
702
+ children: label
703
+ },
704
+ value
705
+ )) })
706
+ ] }) : null,
605
707
  /* @__PURE__ */ jsx2("div", { className: "flex gap-2", children: [
606
708
  ["all", copy.filtersAll, counts.all],
607
- ["open", copy.filtersOpen, counts.open],
608
- ["closed", copy.filtersClosed, counts.closed]
709
+ ["unresolved", copy.filtersUnresolved, counts.unresolved],
710
+ ["resolved", copy.filtersResolved, counts.resolved]
609
711
  ].map(([value, label, count]) => /* @__PURE__ */ jsxs(
610
712
  "button",
611
713
  {
@@ -650,7 +752,7 @@ function IssueReportModal() {
650
752
  const [note, setNote] = useState2("");
651
753
  const [submitError, setSubmitError] = useState2(null);
652
754
  const { isOpen, mode, issue, target, isHydrating, error } = modalState;
653
- useEffect(() => {
755
+ useEffect2(() => {
654
756
  if (!isOpen) {
655
757
  setNote("");
656
758
  setSubmitError(null);
@@ -693,9 +795,7 @@ function IssueReportModal() {
693
795
  }
694
796
  closeModal();
695
797
  } catch (submissionError) {
696
- setSubmitError(
697
- submissionError instanceof Error ? submissionError.message : "Failed to submit issue report"
698
- );
798
+ setSubmitError(resolveErrorMessage(submissionError, "Failed to submit issue report"));
699
799
  }
700
800
  };
701
801
  return /* @__PURE__ */ jsx2(Dialog.Root, { open: isOpen, onOpenChange: (open) => !open && closeModal(), children: /* @__PURE__ */ jsxs(Dialog.Portal, { children: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spaps-issue-reporting-react",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Shared React issue-reporting UI for Sweet Potato platform consumers",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -72,4 +72,4 @@
72
72
  "engines": {
73
73
  "node": ">=18.0.0"
74
74
  }
75
- }
75
+ }