mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-20 03:00:33 +00:00
Add Foundry dev panel
This commit is contained in:
parent
859ad13934
commit
f365342dcc
4 changed files with 397 additions and 0 deletions
|
|
@ -26,6 +26,12 @@ export interface FoundryAppClient {
|
||||||
resumeSubscription(organizationId: string): Promise<void>;
|
resumeSubscription(organizationId: string): Promise<void>;
|
||||||
reconnectGithub(organizationId: string): Promise<void>;
|
reconnectGithub(organizationId: string): Promise<void>;
|
||||||
recordSeatUsage(workspaceId: string): Promise<void>;
|
recordSeatUsage(workspaceId: string): Promise<void>;
|
||||||
|
setMockDebugOrganizationState?(input: {
|
||||||
|
organizationId: string;
|
||||||
|
githubSyncStatus?: "pending" | "syncing" | "synced" | "error";
|
||||||
|
githubInstallationStatus?: "connected" | "install_required" | "reconnect_required";
|
||||||
|
runtimeStatus?: "healthy" | "error";
|
||||||
|
}): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateFoundryAppClientOptions {
|
export interface CreateFoundryAppClientOptions {
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,12 @@ export interface MockFoundryAppClient {
|
||||||
resumeSubscription(organizationId: string): Promise<void>;
|
resumeSubscription(organizationId: string): Promise<void>;
|
||||||
reconnectGithub(organizationId: string): Promise<void>;
|
reconnectGithub(organizationId: string): Promise<void>;
|
||||||
recordSeatUsage(workspaceId: string): void;
|
recordSeatUsage(workspaceId: string): void;
|
||||||
|
setMockDebugOrganizationState(input: {
|
||||||
|
organizationId: string;
|
||||||
|
githubSyncStatus?: MockGithubSyncStatus;
|
||||||
|
githubInstallationStatus?: MockGithubInstallationStatus;
|
||||||
|
runtimeStatus?: MockActorRuntimeStatus;
|
||||||
|
}): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = "sandbox-agent-foundry:mock-app:v1";
|
const STORAGE_KEY = "sandbox-agent-foundry:mock-app:v1";
|
||||||
|
|
@ -174,6 +180,22 @@ function buildHealthyRuntimeState(): MockFoundryActorRuntimeState {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildSampleRuntimeIssue(organization: MockFoundryOrganization): MockFoundryActorRuntimeIssue {
|
||||||
|
return {
|
||||||
|
actorId: `${organization.id}:github-state`,
|
||||||
|
actorType: "organization",
|
||||||
|
scopeId: organization.id,
|
||||||
|
scopeLabel: `${organization.settings.displayName} organization`,
|
||||||
|
message: "GitHub sync failed while refreshing repositories",
|
||||||
|
workflowId: "github-sync",
|
||||||
|
stepName: "full-sync",
|
||||||
|
attempt: 2,
|
||||||
|
willRetry: true,
|
||||||
|
retryDelayMs: 5000,
|
||||||
|
occurredAt: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function buildDefaultSnapshot(): MockFoundryAppSnapshot {
|
function buildDefaultSnapshot(): MockFoundryAppSnapshot {
|
||||||
return {
|
return {
|
||||||
auth: {
|
auth: {
|
||||||
|
|
@ -671,6 +693,50 @@ class MockFoundryAppStore implements MockFoundryAppClient {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setMockDebugOrganizationState(input: {
|
||||||
|
organizationId: string;
|
||||||
|
githubSyncStatus?: MockGithubSyncStatus;
|
||||||
|
githubInstallationStatus?: MockGithubInstallationStatus;
|
||||||
|
runtimeStatus?: MockActorRuntimeStatus;
|
||||||
|
}): Promise<void> {
|
||||||
|
await this.injectAsyncLatency();
|
||||||
|
this.requireOrganization(input.organizationId);
|
||||||
|
this.updateOrganization(input.organizationId, (organization) => {
|
||||||
|
const nextIssues =
|
||||||
|
input.runtimeStatus === "error" ? (organization.runtime.issues.length > 0 ? organization.runtime.issues : [buildSampleRuntimeIssue(organization)]) : [];
|
||||||
|
const nextRuntime =
|
||||||
|
input.runtimeStatus != null
|
||||||
|
? {
|
||||||
|
...organization.runtime,
|
||||||
|
status: input.runtimeStatus,
|
||||||
|
errorCount: nextIssues.length,
|
||||||
|
lastErrorAt: nextIssues[0]?.occurredAt ?? null,
|
||||||
|
issues: nextIssues,
|
||||||
|
}
|
||||||
|
: organization.runtime;
|
||||||
|
const nextSyncStatus = input.githubSyncStatus ?? organization.github.syncStatus;
|
||||||
|
return {
|
||||||
|
...organization,
|
||||||
|
github: {
|
||||||
|
...organization.github,
|
||||||
|
syncStatus: nextSyncStatus,
|
||||||
|
installationStatus: input.githubInstallationStatus ?? organization.github.installationStatus,
|
||||||
|
importedRepoCount: nextSyncStatus === "synced" ? organization.repoCatalog.length : organization.github.importedRepoCount,
|
||||||
|
lastSyncLabel:
|
||||||
|
nextSyncStatus === "syncing"
|
||||||
|
? "Syncing repositories..."
|
||||||
|
: nextSyncStatus === "error"
|
||||||
|
? "GitHub sync failed"
|
||||||
|
: nextSyncStatus === "pending"
|
||||||
|
? "Waiting to sync"
|
||||||
|
: "Synced just now",
|
||||||
|
lastSyncAt: nextSyncStatus === "synced" ? Date.now() : organization.github.lastSyncAt,
|
||||||
|
},
|
||||||
|
runtime: nextRuntime,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
recordSeatUsage(workspaceId: string): void {
|
recordSeatUsage(workspaceId: string): void {
|
||||||
const org = this.snapshot.organizations.find((candidate) => candidate.workspaceId === workspaceId);
|
const org = this.snapshot.organizations.find((candidate) => candidate.workspaceId === workspaceId);
|
||||||
const currentUser = currentMockUser(this.snapshot);
|
const currentUser = currentMockUser(this.snapshot);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { type ReactNode, useEffect } from "react";
|
||||||
import { setFrontendErrorContext } from "@sandbox-agent/foundry-frontend-errors/client";
|
import { setFrontendErrorContext } from "@sandbox-agent/foundry-frontend-errors/client";
|
||||||
import type { FoundryBillingPlanId } from "@sandbox-agent/foundry-shared";
|
import type { FoundryBillingPlanId } from "@sandbox-agent/foundry-shared";
|
||||||
import { Navigate, Outlet, createRootRoute, createRoute, createRouter, useRouterState } from "@tanstack/react-router";
|
import { Navigate, Outlet, createRootRoute, createRoute, createRouter, useRouterState } from "@tanstack/react-router";
|
||||||
|
import { DevPanel } from "../components/dev-panel";
|
||||||
import { MockLayout } from "../components/mock-layout";
|
import { MockLayout } from "../components/mock-layout";
|
||||||
import {
|
import {
|
||||||
MockAccountSettingsPage,
|
MockAccountSettingsPage,
|
||||||
|
|
@ -344,6 +345,7 @@ function RootLayout() {
|
||||||
<>
|
<>
|
||||||
<RouteContextSync />
|
<RouteContextSync />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
<DevPanel />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
323
foundry/packages/frontend/src/components/dev-panel.tsx
Normal file
323
foundry/packages/frontend/src/components/dev-panel.tsx
Normal file
|
|
@ -0,0 +1,323 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useRouterState } from "@tanstack/react-router";
|
||||||
|
import { Bug, RefreshCw, ShieldAlert, Wifi } from "lucide-react";
|
||||||
|
import { useFoundryTokens } from "../app/theme";
|
||||||
|
import { isMockFrontendClient } from "../lib/env";
|
||||||
|
import { activeMockOrganization, activeMockUser, eligibleOrganizations, useMockAppClient, useMockAppSnapshot } from "../lib/mock-app";
|
||||||
|
|
||||||
|
const DEV_PANEL_STORAGE_KEY = "sandbox-agent-foundry:dev-panel-visible";
|
||||||
|
|
||||||
|
function readStoredVisibility(): boolean {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stored = window.localStorage.getItem(DEV_PANEL_STORAGE_KEY);
|
||||||
|
return stored == null ? true : stored === "true";
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStoredVisibility(value: boolean): void {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(DEV_PANEL_STORAGE_KEY, String(value));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionStyle(borderColor: string, background: string) {
|
||||||
|
return {
|
||||||
|
display: "grid",
|
||||||
|
gap: "10px",
|
||||||
|
padding: "12px",
|
||||||
|
borderRadius: "12px",
|
||||||
|
border: `1px solid ${borderColor}`,
|
||||||
|
background,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelStyle(color: string) {
|
||||||
|
return {
|
||||||
|
fontSize: "11px",
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
textTransform: "uppercase" as const,
|
||||||
|
color,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DevPanel() {
|
||||||
|
if (!import.meta.env.DEV) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = useMockAppClient();
|
||||||
|
const snapshot = useMockAppSnapshot();
|
||||||
|
const organization = activeMockOrganization(snapshot);
|
||||||
|
const user = activeMockUser(snapshot);
|
||||||
|
const organizations = eligibleOrganizations(snapshot);
|
||||||
|
const t = useFoundryTokens();
|
||||||
|
const location = useRouterState({ select: (state) => state.location });
|
||||||
|
const [visible, setVisible] = useState<boolean>(() => readStoredVisibility());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
writeStoredVisibility(visible);
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.shiftKey && event.key.toLowerCase() === "d") {
|
||||||
|
event.preventDefault();
|
||||||
|
setVisible((current) => !current);
|
||||||
|
}
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
setVisible(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const modeLabel = isMockFrontendClient ? "Mock" : "Live";
|
||||||
|
const github = organization?.github ?? null;
|
||||||
|
const runtime = organization?.runtime ?? null;
|
||||||
|
const runtimeSummary = useMemo(() => {
|
||||||
|
if (!runtime || runtime.errorCount === 0) {
|
||||||
|
return "No actor errors";
|
||||||
|
}
|
||||||
|
return runtime.errorCount === 1 ? "1 actor error" : `${runtime.errorCount} actor errors`;
|
||||||
|
}, [runtime]);
|
||||||
|
|
||||||
|
const pillButtonStyle = useCallback(
|
||||||
|
(active = false) =>
|
||||||
|
({
|
||||||
|
border: `1px solid ${active ? t.accent : t.borderDefault}`,
|
||||||
|
background: active ? t.surfacePrimary : t.surfaceSecondary,
|
||||||
|
color: t.textPrimary,
|
||||||
|
borderRadius: "999px",
|
||||||
|
padding: "6px 10px",
|
||||||
|
fontSize: "11px",
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: "pointer",
|
||||||
|
}) as const,
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!visible) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVisible(true)}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
right: "16px",
|
||||||
|
bottom: "16px",
|
||||||
|
zIndex: 1000,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "8px",
|
||||||
|
border: `1px solid ${t.borderDefault}`,
|
||||||
|
background: t.surfacePrimary,
|
||||||
|
color: t.textPrimary,
|
||||||
|
borderRadius: "999px",
|
||||||
|
padding: "10px 14px",
|
||||||
|
boxShadow: "0 18px 40px rgba(0, 0, 0, 0.28)",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Bug size={14} />
|
||||||
|
Dev
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
right: "16px",
|
||||||
|
bottom: "16px",
|
||||||
|
width: "360px",
|
||||||
|
maxHeight: "calc(100vh - 32px)",
|
||||||
|
overflowY: "auto",
|
||||||
|
zIndex: 1000,
|
||||||
|
borderRadius: "18px",
|
||||||
|
border: `1px solid ${t.borderDefault}`,
|
||||||
|
background: t.surfacePrimary,
|
||||||
|
color: t.textPrimary,
|
||||||
|
boxShadow: "0 24px 60px rgba(0, 0, 0, 0.35)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "sticky",
|
||||||
|
top: 0,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "14px 16px",
|
||||||
|
borderBottom: `1px solid ${t.borderDefault}`,
|
||||||
|
background: t.surfacePrimary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "grid", gap: "2px" }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||||
|
<Bug size={14} />
|
||||||
|
<strong style={{ fontSize: "13px" }}>Dev Panel</strong>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "10px",
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: t.textMuted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{modeLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "11px", color: t.textMuted }}>{location.pathname}</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => setVisible(false)} style={pillButtonStyle()}>
|
||||||
|
Hide
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gap: "12px", padding: "14px" }}>
|
||||||
|
<div style={sectionStyle(t.borderSubtle, t.surfaceSecondary)}>
|
||||||
|
<div style={labelStyle(t.textMuted)}>Session</div>
|
||||||
|
<div style={{ display: "grid", gap: "4px", fontSize: "12px" }}>
|
||||||
|
<div>Auth: {snapshot.auth.status}</div>
|
||||||
|
<div>User: {user ? `${user.name} (@${user.githubLogin})` : "None"}</div>
|
||||||
|
<div>Organization: {organization?.settings.displayName ?? "None selected"}</div>
|
||||||
|
</div>
|
||||||
|
{isMockFrontendClient ? (
|
||||||
|
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
|
||||||
|
{snapshot.auth.status === "signed_in" ? (
|
||||||
|
<button type="button" onClick={() => void client.signOut()} style={pillButtonStyle()}>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
snapshot.users.map((candidate) => (
|
||||||
|
<button key={candidate.id} type="button" onClick={() => void client.signInWithGithub(candidate.id)} style={pillButtonStyle()}>
|
||||||
|
Sign in as {candidate.githubLogin}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={sectionStyle(t.borderSubtle, t.surfaceSecondary)}>
|
||||||
|
<div style={labelStyle(t.textMuted)}>GitHub</div>
|
||||||
|
<div style={{ display: "grid", gap: "4px", fontSize: "12px" }}>
|
||||||
|
<div>Installation: {github?.installationStatus ?? "n/a"}</div>
|
||||||
|
<div>Sync: {github?.syncStatus ?? "n/a"}</div>
|
||||||
|
<div>Repos: {github?.importedRepoCount ?? 0}</div>
|
||||||
|
<div>Last sync: {github?.lastSyncLabel ?? "n/a"}</div>
|
||||||
|
</div>
|
||||||
|
{organization ? (
|
||||||
|
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
|
||||||
|
<button type="button" onClick={() => void client.triggerGithubSync(organization.id)} style={pillButtonStyle()}>
|
||||||
|
<RefreshCw size={12} style={{ marginRight: "6px", verticalAlign: "text-bottom" }} />
|
||||||
|
Sync
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => void client.reconnectGithub(organization.id)} style={pillButtonStyle()}>
|
||||||
|
<Wifi size={12} style={{ marginRight: "6px", verticalAlign: "text-bottom" }} />
|
||||||
|
Reconnect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{isMockFrontendClient && organization && client.setMockDebugOrganizationState ? (
|
||||||
|
<div style={{ display: "grid", gap: "8px" }}>
|
||||||
|
<div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }}>
|
||||||
|
{(["pending", "syncing", "synced", "error"] as const).map((status) => (
|
||||||
|
<button
|
||||||
|
key={status}
|
||||||
|
type="button"
|
||||||
|
onClick={() => void client.setMockDebugOrganizationState?.({ organizationId: organization.id, githubSyncStatus: status })}
|
||||||
|
style={pillButtonStyle(github?.syncStatus === status)}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }}>
|
||||||
|
{(["connected", "install_required", "reconnect_required"] as const).map((status) => (
|
||||||
|
<button
|
||||||
|
key={status}
|
||||||
|
type="button"
|
||||||
|
onClick={() => void client.setMockDebugOrganizationState?.({ organizationId: organization.id, githubInstallationStatus: status })}
|
||||||
|
style={pillButtonStyle(github?.installationStatus === status)}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={sectionStyle(t.borderSubtle, t.surfaceSecondary)}>
|
||||||
|
<div style={labelStyle(t.textMuted)}>Runtime</div>
|
||||||
|
<div style={{ display: "grid", gap: "4px", fontSize: "12px" }}>
|
||||||
|
<div>Status: {runtime?.status ?? "n/a"}</div>
|
||||||
|
<div>{runtimeSummary}</div>
|
||||||
|
{runtime?.issues[0] ? <div>Latest: {runtime.issues[0].message}</div> : null}
|
||||||
|
</div>
|
||||||
|
{organization ? (
|
||||||
|
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
|
||||||
|
{isMockFrontendClient && client.setMockDebugOrganizationState ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void client.setMockDebugOrganizationState?.({ organizationId: organization.id, runtimeStatus: "error" })}
|
||||||
|
style={pillButtonStyle(runtime?.status === "error")}
|
||||||
|
>
|
||||||
|
<ShieldAlert size={12} style={{ marginRight: "6px", verticalAlign: "text-bottom" }} />
|
||||||
|
Show error
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void client.setMockDebugOrganizationState?.({ organizationId: organization.id, runtimeStatus: "healthy" })}
|
||||||
|
style={pillButtonStyle(runtime?.status === "healthy")}
|
||||||
|
>
|
||||||
|
Healthy
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{runtime?.errorCount ? (
|
||||||
|
<button type="button" onClick={() => void client.clearOrganizationRuntimeIssues(organization.id)} style={pillButtonStyle()}>
|
||||||
|
Clear actor errors
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isMockFrontendClient && organizations.length > 0 ? (
|
||||||
|
<div style={sectionStyle(t.borderSubtle, t.surfaceSecondary)}>
|
||||||
|
<div style={labelStyle(t.textMuted)}>Mock Organization</div>
|
||||||
|
<div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }}>
|
||||||
|
{organizations.map((candidate) => (
|
||||||
|
<button
|
||||||
|
key={candidate.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => void client.selectOrganization(candidate.id)}
|
||||||
|
style={pillButtonStyle(organization?.id === candidate.id)}
|
||||||
|
>
|
||||||
|
{candidate.settings.displayName}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue