Merge remote-tracking branch 'origin/master' into fix/runner-paid-matrix-integrity

* origin/master:
  feat(ui): viewer=full document deep link opens the maximized side pane (#12812)
  feat(agents): grant new agents hire permission by default (#12814)
  fix(ui): remove the Account badge and version line from the account menu (#12818)
This commit is contained in:
Dotta 2026-09-04 01:58:16 -05:00
commit 57355011cb
24 changed files with 535 additions and 224 deletions

View File

@ -716,8 +716,10 @@ Preview/install options:
`paperclipai company current --json`, or `PAPERCLIP_COMPANY_ID` to select the
target company. `company list` falls back to the scoped current company when
board-wide listing is forbidden. `teams install` creates agents and therefore
requires board authentication, an `agents:create` grant, or an agent with
explicit `canCreateAgents` permission.
requires board authentication, an `agents:create` grant, or an agent with the
`canCreateAgents` permission (enabled by default for newly created
standard-trust agents; low-trust agents and pre-existing agents without an
explicit value stay disabled).
- `--request-approval-on-forbidden` turns a 403 install denial into a linked
board approval request instead of a raw failed command; use
`--approval-issue-id <id>` to attach it to a specific issue. During Paperclip

View File

@ -12,7 +12,9 @@ import { agentDesiredSkillSelectionSchema } from "./adapter-skills.js";
import { objectWithoutDefaults } from "./partial.js";
export const agentPermissionsSchema = z.object({
canCreateAgents: z.boolean().optional().default(false),
// No schema default: the server derives the default (enabled unless the
// permissions record marks the agent low-trust) when the field is omitted.
canCreateAgents: z.boolean().optional(),
canCreateSkills: z.boolean().optional().default(true),
trustPreset: trustPresetSchema.optional(),
authorizationPolicy: trustAuthorizationPolicySchema.optional(),

View File

@ -1,37 +1,103 @@
import { describe, expect, it } from "vitest";
import {
LOW_TRUST_REVIEW_PRESET,
agentPermissionsSchema,
updateAgentPermissionsSchema,
} from "@paperclipai/shared";
import {
defaultPermissionsForRole,
defaultAgentPermissions,
normalizeAgentPermissions,
permissionsImplyLowTrust,
} from "../services/agent-permissions.js";
describe("agent permissions service", () => {
it("keeps agent-creation authority least-privileged by default", () => {
expect(defaultPermissionsForRole("ceo").canCreateAgents).toBe(true);
expect(defaultPermissionsForRole("CTO").canCreateAgents).toBe(false);
expect(defaultPermissionsForRole("engineering-manager").canCreateAgents).toBe(false);
expect(defaultPermissionsForRole("engineer").canCreateAgents).toBe(false);
it("grants agent-creation authority to new agents by default", () => {
expect(defaultAgentPermissions({ context: "create" }).canCreateAgents).toBe(true);
expect(normalizeAgentPermissions(undefined, { context: "create" }).canCreateAgents).toBe(true);
expect(normalizeAgentPermissions({}, { context: "create" }).canCreateAgents).toBe(true);
expect(
normalizeAgentPermissions({ trustPreset: "standard" }, { context: "create" }).canCreateAgents,
).toBe(true);
});
it("enables skill creation for every role by default", () => {
expect(defaultPermissionsForRole("ceo").canCreateSkills).toBe(true);
expect(defaultPermissionsForRole("CTO").canCreateSkills).toBe(true);
expect(defaultPermissionsForRole("engineering-manager").canCreateSkills).toBe(true);
expect(defaultPermissionsForRole("engineer").canCreateSkills).toBe(true);
it("keeps stored rows without an explicit value fail-closed", () => {
expect(defaultAgentPermissions().canCreateAgents).toBe(false);
expect(defaultAgentPermissions({ context: "stored" }).canCreateAgents).toBe(false);
expect(normalizeAgentPermissions(undefined).canCreateAgents).toBe(false);
expect(normalizeAgentPermissions({}).canCreateAgents).toBe(false);
expect(normalizeAgentPermissions("malformed").canCreateAgents).toBe(false);
expect(normalizeAgentPermissions([]).canCreateAgents).toBe(false);
});
it("preserves explicit canCreateAgents overrides", () => {
expect(normalizeAgentPermissions({ canCreateAgents: false }, "cto").canCreateAgents).toBe(false);
expect(normalizeAgentPermissions({ canCreateAgents: true }, "engineer").canCreateAgents).toBe(true);
it("withholds agent-creation authority from new low-trust agents", () => {
expect(defaultAgentPermissions({ lowTrust: true, context: "create" }).canCreateAgents).toBe(false);
expect(
normalizeAgentPermissions(
{ trustPreset: LOW_TRUST_REVIEW_PRESET },
{ context: "create" },
).canCreateAgents,
).toBe(false);
expect(
normalizeAgentPermissions(
{ authorizationPolicy: { trustPreset: LOW_TRUST_REVIEW_PRESET } },
{ context: "create" },
).canCreateAgents,
).toBe(false);
expect(
normalizeAgentPermissions(
{ authorizationPolicy: { trustBoundary: { mode: LOW_TRUST_REVIEW_PRESET } } },
{ context: "create" },
).canCreateAgents,
).toBe(false);
});
it("detects low-trust markers wherever the trust policy stores them", () => {
expect(permissionsImplyLowTrust(undefined)).toBe(false);
expect(permissionsImplyLowTrust({})).toBe(false);
expect(permissionsImplyLowTrust({ trustPreset: "standard" })).toBe(false);
expect(permissionsImplyLowTrust({ trustPreset: LOW_TRUST_REVIEW_PRESET })).toBe(true);
expect(permissionsImplyLowTrust({ reviewPreset: { id: LOW_TRUST_REVIEW_PRESET } })).toBe(true);
expect(
permissionsImplyLowTrust({ authorizationPolicy: { trustPreset: LOW_TRUST_REVIEW_PRESET } }),
).toBe(true);
expect(
permissionsImplyLowTrust({
authorizationPolicy: { reviewPreset: { id: LOW_TRUST_REVIEW_PRESET } },
}),
).toBe(true);
expect(
permissionsImplyLowTrust({
authorizationPolicy: { trustBoundary: { mode: LOW_TRUST_REVIEW_PRESET } },
}),
).toBe(true);
});
it("enables skill creation by default", () => {
expect(defaultAgentPermissions().canCreateSkills).toBe(true);
expect(defaultAgentPermissions({ lowTrust: true, context: "create" }).canCreateSkills).toBe(true);
});
it("preserves explicit canCreateAgents overrides in both contexts", () => {
expect(normalizeAgentPermissions({ canCreateAgents: false }, { context: "create" }).canCreateAgents).toBe(false);
expect(normalizeAgentPermissions({ canCreateAgents: true }).canCreateAgents).toBe(true);
expect(
normalizeAgentPermissions({
canCreateAgents: true,
trustPreset: LOW_TRUST_REVIEW_PRESET,
}).canCreateAgents,
).toBe(true);
});
it("defaults missing skill creation permission to true and preserves explicit false", () => {
expect(normalizeAgentPermissions({}, "engineer").canCreateSkills).toBe(true);
expect(normalizeAgentPermissions({ canCreateSkills: false }, "ceo").canCreateSkills).toBe(false);
expect(normalizeAgentPermissions({ canCreateSkills: true }, "engineer").canCreateSkills).toBe(true);
expect(normalizeAgentPermissions({}).canCreateSkills).toBe(true);
expect(normalizeAgentPermissions({ canCreateSkills: false }).canCreateSkills).toBe(false);
expect(normalizeAgentPermissions({ canCreateSkills: true }).canCreateSkills).toBe(true);
});
it("leaves omitted canCreateAgents undefined at the schema layer", () => {
expect(agentPermissionsSchema.parse({}).canCreateAgents).toBeUndefined();
expect(agentPermissionsSchema.parse({ canCreateAgents: false }).canCreateAgents).toBe(false);
expect(agentPermissionsSchema.parse({ canCreateAgents: true }).canCreateAgents).toBe(true);
});
it("validates skill creation permission with a default-on value", () => {

View File

@ -1928,6 +1928,71 @@ describeEmbeddedPostgres("authorization service", () => {
});
});
it("grants hire authority through the persisted new-agent default", async () => {
const company = await createCompany(db, "DefaultHire");
// The service create path persists the create-context default
// (canCreateAgents: true for standard trust); enforcement reads it back.
const actorAgent = await createAgent(db, company.id, {
role: "engineer",
permissions: { canCreateAgents: true, canCreateSkills: true },
});
const decision = await authorizationService(db).decide({
actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_jwt" },
action: "agents:create",
resource: { type: "company", companyId: company.id },
});
expect(decision).toMatchObject({
allowed: true,
reason: "allow_legacy_agent_creator",
});
});
it("keeps legacy rows without an explicit canCreateAgents fail-closed", async () => {
const company = await createCompany(db, "LegacyRowFailClosed");
const actorAgent = await createAgent(db, company.id, { role: "engineer", permissions: {} });
const decision = await authorizationService(db).decide({
actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_jwt" },
action: "agents:create",
resource: { type: "company", companyId: company.id },
});
expect(decision).toMatchObject({
allowed: false,
reason: "deny_missing_grant",
});
});
it("denies agent creation for a low-trust boundary even with explicit canCreateAgents", async () => {
const company = await createCompany(db, "LowTrustHireDenied");
const project = await createProject(db, company.id, "Contained");
const actorAgent = await createAgent(db, company.id, {
permissions: {
canCreateAgents: true,
trustPreset: LOW_TRUST_REVIEW_PRESET,
authorizationPolicy: {
trustBoundary: {
mode: LOW_TRUST_REVIEW_PRESET,
projectIds: [project.id],
},
},
},
});
const decision = await authorizationService(db).decide({
actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_jwt" },
action: "agents:create",
resource: { type: "company", companyId: company.id },
});
expect(decision).toMatchObject({
allowed: false,
reason: "deny_low_trust_boundary",
});
});
it("denies active-checkout management outside the CEO caller company scope", async () => {
const sourceCompany = await createCompany(db, "CheckoutSource");
const targetCompany = await createCompany(db, "CheckoutTarget");

View File

@ -1,28 +1,68 @@
import { LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared";
export type NormalizedAgentPermissions = Record<string, unknown> & {
canCreateAgents: boolean;
canCreateSkills: boolean;
};
export function defaultPermissionsForRole(role: string): NormalizedAgentPermissions {
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
/**
* Mirrors the agent-source low-trust markers consumed by
* resolveCoreTrustPreset: the low-trust review preset (top-level or inside
* authorizationPolicy) or a low-trust boundary. Defaults must never grant
* agent-creation authority to a low-trust agent.
*/
export function permissionsImplyLowTrust(permissions: unknown): boolean {
const record = asRecord(permissions);
if (!record) return false;
const authorizationPolicy = asRecord(record.authorizationPolicy);
return (
record.trustPreset === LOW_TRUST_REVIEW_PRESET ||
authorizationPolicy?.trustPreset === LOW_TRUST_REVIEW_PRESET ||
asRecord(record.reviewPreset)?.id === LOW_TRUST_REVIEW_PRESET ||
asRecord(authorizationPolicy?.reviewPreset)?.id === LOW_TRUST_REVIEW_PRESET ||
asRecord(authorizationPolicy?.trustBoundary) !== null
);
}
/**
* "create" is the context for permissions arriving on a new-agent write: the
* hire/create default applies and the resolved value is persisted. "stored"
* is the context for rows read back from the database: a row without an
* explicit value stays fail-closed, so the default is never granted
* retroactively to legacy or malformed records at read or enforcement time.
*/
export type AgentPermissionsContext = "create" | "stored";
export function defaultAgentPermissions(
options?: { lowTrust?: boolean; context?: AgentPermissionsContext },
): NormalizedAgentPermissions {
return {
canCreateAgents: role.trim().toLowerCase() === "ceo",
canCreateAgents: options?.context === "create" && options?.lowTrust !== true,
canCreateSkills: true,
};
}
export function normalizeAgentPermissions(
permissions: unknown,
role: string,
options?: { context?: AgentPermissionsContext },
): NormalizedAgentPermissions {
const defaults = defaultPermissionsForRole(role);
if (typeof permissions !== "object" || permissions === null || Array.isArray(permissions)) {
const defaults = defaultAgentPermissions({
lowTrust: permissionsImplyLowTrust(permissions),
context: options?.context ?? "stored",
});
const record = asRecord(permissions);
if (!record) {
return defaults;
}
const record = permissions as Record<string, unknown>;
const preserved = { ...record };
return {
...preserved,
...record,
canCreateAgents:
typeof record.canCreateAgents === "boolean"
? record.canCreateAgents

View File

@ -344,7 +344,7 @@ export function agentService(db: Db) {
function normalizeAgentBaseRow(row: typeof agents.$inferSelect) {
return withUrlKey({
...row,
permissions: normalizeAgentPermissions(row.permissions, row.role),
permissions: normalizeAgentPermissions(row.permissions),
});
}
@ -676,8 +676,7 @@ export function agentService(db: Db) {
const normalizedPatch = { ...data } as Partial<typeof agents.$inferInsert>;
if (data.permissions !== undefined) {
const role = (data.role ?? existing.role) as string;
normalizedPatch.permissions = normalizeAgentPermissions(data.permissions, role);
normalizedPatch.permissions = normalizeAgentPermissions(data.permissions);
}
if (
Object.prototype.hasOwnProperty.call(normalizedPatch, "adapterConfig") &&
@ -806,7 +805,7 @@ export function agentService(db: Db) {
const uniqueName = deduplicateAgentName(data.name, existingAgents);
const role = data.role ?? "general";
const normalizedPermissions = normalizeAgentPermissions(data.permissions, role);
const normalizedPermissions = normalizeAgentPermissions(data.permissions, { context: "create" });
const runtimeConfig = normalizeRuntimeConfigForNewAgent(data.runtimeConfig);
const adapterType = data.adapterType ?? "process";
const rawAdapterConfig = isPlainRecord(data.adapterConfig)
@ -1039,10 +1038,9 @@ export function agentService(db: Db) {
);
}
if (patch.permissions !== undefined) {
patch.permissions = normalizeAgentPermissions(
patch.permissions,
(patch.role ?? existing.role) as string,
);
// The pending-approval activation replays the original hire
// request, so the new-agent creation default applies.
patch.permissions = normalizeAgentPermissions(patch.permissions, { context: "create" });
}
const updated = await tx
.update(agents)
@ -1089,7 +1087,7 @@ export function agentService(db: Db) {
const updated = await db
.update(agents)
.set({
permissions: normalizeAgentPermissions({ ...existing.permissions, ...permissions }, existing.role),
permissions: normalizeAgentPermissions({ ...existing.permissions, ...permissions }),
updatedAt: new Date(),
})
.where(eq(agents.id, id))

View File

@ -28,6 +28,7 @@ import {
type TrustPresetResolution,
} from "./trust-preset-resolver.js";
import { logger } from "../middleware/logger.js";
import { normalizeAgentPermissions } from "./agent-permissions.js";
import { grantsForHumanRole, normalizeHumanRole } from "./company-member-roles.js";
export type AuthorizationActor =
@ -170,8 +171,10 @@ function permissionForAction(action: AuthorizationAction): PermissionKey | null
function canCreateAgentsLegacy(agent: { role: string; permissions: unknown }) {
if (agent.role === "ceo") return true;
if (!agent.permissions || typeof agent.permissions !== "object") return false;
return Boolean((agent.permissions as Record<string, unknown>).canCreateAgents);
// Raw agent rows may predate permission normalization; apply the same
// defaults the agent service applies on read so enforcement matches what
// the API reports.
return normalizeAgentPermissions(agent.permissions).canCreateAgents;
}
function scopeValueList(value: unknown): string[] {
@ -984,6 +987,11 @@ export function authorizationService(db: Db | DbTransaction) {
if (
input.action === "company_scope:read" ||
// Agent creation is a company-wide privileged action. The default-on
// canCreateAgents flag must never reach the legacy creator allow when
// the effective execution context (agent, project, issue, or run
// policy) resolves to low trust.
input.action === "agents:create" ||
input.action === "decision_queue:manage" ||
input.action === "decision_queue:read" ||
input.action === "decision_triage:manage" ||
@ -2242,11 +2250,19 @@ export function authorizationService(db: Db | DbTransaction) {
if (grantDecision.allowed) return grantDecision;
}
if (
(input.action === "agents:create" ||
input.action === "tasks:manage_active_checkouts") &&
canCreateAgentsLegacy(actorAgent)
) {
if (input.action === "agents:create" && canCreateAgentsLegacy(actorAgent)) {
return allow({
action: input.action,
reason: "allow_legacy_agent_creator",
explanation: "Allowed by legacy agent creator authority.",
});
}
// Active-checkout management deliberately does not ride on
// canCreateAgents: that flag is default-on for standard-trust agents, and
// coupling would let any peer write over another agent's checked-out
// issue. CEOs, explicit grants, and the manager chain remain the paths.
if (input.action === "tasks:manage_active_checkouts" && actorAgent.role === "ceo") {
return allow({
action: input.action,
reason: "allow_legacy_agent_creator",

View File

@ -604,8 +604,6 @@ export function Layout() {
</div>
<SidebarAccountMenu
deploymentMode={health?.deploymentMode}
serverGit={health?.serverInfo?.git}
version={health?.version}
/>
</div>
) : (
@ -624,8 +622,6 @@ export function Layout() {
</div>
<SidebarAccountMenu
deploymentMode={health?.deploymentMode}
serverGit={health?.serverInfo?.git}
version={health?.version}
/>
</SidebarShell>
)}

View File

@ -659,8 +659,6 @@ export function Layout() {
</div>
<SidebarAccountMenu
deploymentMode={health?.deploymentMode}
serverGit={health?.serverInfo?.git}
version={health?.version}
forceExpanded={replacesPrimarySidebar}
/>
</div>
@ -684,8 +682,6 @@ export function Layout() {
</div>
<SidebarAccountMenu
deploymentMode={health?.deploymentMode}
serverGit={health?.serverInfo?.git}
version={health?.version}
forceExpanded={replacesPrimarySidebar}
/>
</SidebarShell>

View File

@ -19,8 +19,10 @@ const mockPanelState = vi.hoisted(() => ({
panelContent: null as unknown,
panelContentMode: "padded" as const,
panelVisible: true,
panelMaximizeRequested: false,
}));
const mockSetPanelVisible = vi.hoisted(() => vi.fn());
const mockClearPanelMaximizeRequest = vi.hoisted(() => vi.fn());
vi.mock("../context/PanelContext", () => ({
usePanel: () => ({
@ -31,6 +33,9 @@ vi.mock("../context/PanelContext", () => ({
closePanel: vi.fn(),
setPanelVisible: mockSetPanelVisible,
togglePanelVisible: vi.fn(),
panelMaximizeRequested: mockPanelState.panelMaximizeRequested,
requestPanelMaximize: vi.fn(),
clearPanelMaximizeRequest: mockClearPanelMaximizeRequest,
}),
}));
@ -74,6 +79,8 @@ describe("PropertiesPanel", () => {
document.body.appendChild(container);
window.localStorage.clear();
mockSetPanelVisible.mockClear();
mockClearPanelMaximizeRequest.mockClear();
mockPanelState.panelMaximizeRequested = false;
});
afterEach(() => {
@ -151,6 +158,20 @@ describe("PropertiesPanel", () => {
expect(container.querySelector("section")?.getAttribute("data-maximized")).toBe("true");
});
it("consumes a pending deep-link maximize request on mount (LOOA-2181)", async () => {
mockPanelState.panelMaximizeRequested = true;
await renderPanel({ taskDetailLayout: true });
expect(mockClearPanelMaximizeRequest).toHaveBeenCalled();
expect(container.querySelector("section")?.getAttribute("data-maximized")).toBe("true");
});
it("holds a deep-link maximize request while the panel is hidden", async () => {
mockPanelState.panelMaximizeRequested = true;
await renderPanel({ panelVisible: false });
expect(mockClearPanelMaximizeRequest).not.toHaveBeenCalled();
expect(container.querySelector("section")?.getAttribute("data-maximized")).not.toBe("true");
});
it("uses an X to close the Streamlined task-detail sidebar", async () => {
await renderPanel({ taskDetailLayout: true });
const close = container.querySelector<HTMLButtonElement>('[aria-label="Close side panel"]');

View File

@ -9,7 +9,14 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { SidePanelFrame, SidePanelWindowControls } from "@/components/side-panel";
export function PropertiesPanel({ taskDetailLayout = false }: { taskDetailLayout?: boolean }) {
const { panelContent, panelContentMode, panelVisible, setPanelVisible } = usePanel();
const {
panelContent,
panelContentMode,
panelVisible,
setPanelVisible,
panelMaximizeRequested,
clearPanelMaximizeRequest,
} = usePanel();
const { enabled: classicTaskInterfaceEnabled } = useClassicTaskInterfaceEnabled();
const { enabled: streamlinedUiEnabled } = useStreamlinedUiEnabled();
const streamlinedTaskDetailLayout = streamlinedUiEnabled && taskDetailLayout;
@ -45,6 +52,8 @@ export function PropertiesPanel({ taskDetailLayout = false }: { taskDetailLayout
panelVisible={panelVisible}
setPanelVisible={setPanelVisible}
taskDetailLayout={streamlinedTaskDetailLayout}
maximizeRequested={panelMaximizeRequested}
clearMaximizeRequest={clearPanelMaximizeRequest}
/>
);
}
@ -144,6 +153,9 @@ interface ResizablePropertiesPanelProps {
panelVisible: boolean;
setPanelVisible: (visible: boolean) => void;
taskDetailLayout: boolean;
/** Pending `viewer=full` deep-link request (LOOA-2181); cleared once consumed. */
maximizeRequested: boolean;
clearMaximizeRequest: () => void;
}
function ResizablePropertiesPanel({
@ -152,6 +164,8 @@ function ResizablePropertiesPanel({
panelVisible,
setPanelVisible,
taskDetailLayout,
maximizeRequested,
clearMaximizeRequest,
}: ResizablePropertiesPanelProps) {
const defaultPaneWidth = taskDetailLayout
? TASK_DETAIL_DEFAULT_PANE_WIDTH
@ -301,6 +315,16 @@ function ResizablePropertiesPanel({
restoreTimerRef.current = window.setTimeout(finishRestore, RESTORE_FALLBACK_DELAY);
}, [clearRestoreTimer, finishRestore]);
// Deep-link maximize (LOOA-2181): the request may predate this mount (the
// hash routes before the panel content commits), so it lives in context and
// is consumed here once the panel is actually visible and laid out —
// handleMaximize measures live geometry, which needs a committed DOM.
useEffect(() => {
if (!maximizeRequested || !panelVisible) return;
clearMaximizeRequest();
if (!maximized) handleMaximize();
}, [maximizeRequested, panelVisible, maximized, handleMaximize, clearMaximizeRequest]);
const handleTransitionEnd = useCallback(
(event: React.TransitionEvent<HTMLElement>) => {
if (event.target !== asideRef.current || event.propertyName !== "left") return;

View File

@ -8,7 +8,7 @@ import {
UserRound,
UserRoundPen,
} from "lucide-react";
import type { DeploymentMode, ServerGitInfo } from "@paperclipai/shared";
import type { DeploymentMode } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { authApi } from "@/api/auth";
import { queryKeys } from "@/lib/queryKeys";
@ -20,20 +20,15 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
import { ThemeToggle } from "./ThemeToggle";
import { SidebarServerInfo } from "./SidebarServerInfo";
import { Badge } from "@/components/ui/badge";
const PROFILE_SETTINGS_PATH = "/company/settings/instance/profile";
const DOCS_URL = "https://docs.paperclip.ing/";
const FEEDBACK_URL = "https://paperclip.ing/feedback";
const SOURCE_REPOSITORY_URL = "https://github.com/paperclipai/paperclip";
const SOURCE_VERSION_RE = /\+\d+\.git\.([0-9a-f]{7,40})(?:\.dirty)?$/i;
interface SidebarAccountMenuProps {
deploymentMode?: DeploymentMode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
serverGit?: ServerGitInfo;
version?: string | null;
}
interface MenuActionProps {
@ -67,11 +62,6 @@ function deriveUserSlug(name: string | null | undefined, email: string | null |
return "me";
}
function sourceVersionSha(version: string): string | null {
const sourceVersion = version.match(SOURCE_VERSION_RE);
return sourceVersion?.[1] ?? null;
}
function MenuAction({ label, description, icon: Icon, onClick, href, external = false }: MenuActionProps) {
const className =
"flex w-full items-start gap-3 rounded-xl px-3 py-3 text-left transition-colors hover:bg-accent/60";
@ -115,8 +105,6 @@ export function SidebarAccountMenu({
deploymentMode,
open: controlledOpen,
onOpenChange,
serverGit,
version,
}: SidebarAccountMenuProps) {
const [internalOpen, setInternalOpen] = useState(false);
const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
@ -134,15 +122,8 @@ export function SidebarAccountMenu({
const displayName = session?.user.name?.trim() || "Board";
const secondaryLabel =
session?.user.email?.trim() || (deploymentMode === "authenticated" ? "Signed in" : "Local workspace board");
const accountBadge = deploymentMode === "authenticated" ? "Account" : "Local";
const initials = deriveInitials(displayName);
const profileHref = `/u/${deriveUserSlug(session?.user.name, session?.user.email, session?.user.id)}`;
const sourceSha = version ? sourceVersionSha(version) : null;
const sourceFullSha =
sourceSha && serverGit?.available && serverGit.fullSha.toLowerCase().startsWith(sourceSha.toLowerCase())
? serverGit.fullSha
: sourceSha;
const sourceBranch = sourceSha && serverGit?.available ? serverGit.branchName : null;
function closeNavigationChrome() {
setOpen(false);
@ -189,40 +170,8 @@ export function SidebarAccountMenu({
</Avatar>
</div>
<div className="min-w-0 flex-1 pt-1">
<div className="flex items-center gap-2">
<h2 className="truncate text-base font-semibold text-foreground">{displayName}</h2>
<Badge variant="ghost" className="bg-accent text-(length:--text-nano) font-semibold uppercase tracking-wide text-muted-foreground">
{accountBadge}
</Badge>
</div>
<h2 className="truncate text-base font-semibold text-foreground">{displayName}</h2>
<p className="truncate text-sm text-muted-foreground">{secondaryLabel}</p>
{sourceSha && sourceFullSha ? (
<div className="mt-1 text-xs text-muted-foreground">
{sourceBranch ? (
<a
href={`${SOURCE_REPOSITORY_URL}/tree/${encodeURIComponent(sourceBranch)}`}
target="_blank"
rel="noreferrer"
className="block truncate transition-colors hover:text-foreground"
>
{sourceBranch}
</a>
) : null}
<p>
Paperclip{" "}
<a
href={`${SOURCE_REPOSITORY_URL}/commit/${sourceFullSha}`}
target="_blank"
rel="noreferrer"
className="transition-colors hover:text-foreground"
>
{sourceSha.slice(0, 7)}
</a>
</p>
</div>
) : version ? (
<p className="mt-1 text-xs text-muted-foreground">Paperclip v{version}</p>
) : null}
</div>
</div>

View File

@ -196,10 +196,7 @@ describe("SidebarAccountMenu", () => {
root.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<SidebarAccountMenu
deploymentMode="authenticated"
version="1.2.3"
/>
<SidebarAccountMenu deploymentMode="authenticated" />
</TooltipProvider>
</QueryClientProvider>,
);
@ -233,7 +230,9 @@ describe("SidebarAccountMenu", () => {
const themePos = menuText.indexOf("Switch to");
expect(docsPos).toBeLessThan(themePos);
expect(document.body.textContent).toContain("Paperclip v1.2.3");
// The popover header stays down to name + email: no "Account" badge, no version line.
expect(popover?.textContent).not.toContain("Account");
expect(popover?.textContent).not.toContain("Paperclip v");
expect(document.body.textContent).toContain("jane@example.com");
expect(document.body.querySelector('[data-slot="popover-content"]')?.className)
.toContain("w-(--sz-277px)");
@ -331,53 +330,4 @@ describe("SidebarAccountMenu", () => {
});
});
it("shows the short commit sha instead of a version for source builds", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<SidebarAccountMenu
deploymentMode="authenticated"
version="2026.626.0+58.git.518fc71ce"
serverGit={{
available: true,
fullSha: "518fc71ce1234567890abcdef1234567890abcde",
shortSha: "518fc71",
branchName: "feature/source-build-label",
subject: "Show source build label",
committedAt: "2026-06-26T00:00:00.000Z",
localChanges: {
available: true,
hasLocalChanges: false,
stagedFileCount: 0,
unstagedFileCount: 0,
untrackedFileCount: 0,
},
}}
open
/>
</TooltipProvider>
</QueryClientProvider>,
);
});
await flushReact();
expect(document.body.textContent).toContain("feature/source-build-labelPaperclip 518fc71");
expect(document.body.textContent).not.toContain("2026.626.0+58.git.518fc71ce");
expect(document.body.querySelector('a[href="https://github.com/paperclipai/paperclip/tree/feature%2Fsource-build-label"]')?.textContent).toBe(
"feature/source-build-label",
);
expect(document.body.querySelector('a[href="https://github.com/paperclipai/paperclip/commit/518fc71ce1234567890abcdef1234567890abcde"]')?.textContent).toBe(
"518fc71",
);
await act(async () => {
root.unmount();
});
});
});

View File

@ -9,7 +9,7 @@ import {
UserRound,
UserRoundPen,
} from "lucide-react";
import type { DeploymentMode, ServerGitInfo } from "@paperclipai/shared";
import type { DeploymentMode } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { authApi } from "@/api/auth";
import { queryKeys } from "@/lib/queryKeys";
@ -21,20 +21,15 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn, SIDEBAR_RAIL_HIDDEN_LABEL } from "../lib/utils";
import { ThemeToggle } from "./ThemeToggle";
import { SidebarServerInfo } from "./SidebarServerInfo";
import { Badge } from "@/components/ui/badge";
const PROFILE_SETTINGS_PATH = "/company/settings/instance/profile";
const DOCS_URL = "https://docs.paperclip.ing/";
const FEEDBACK_URL = "https://paperclip.ing/feedback";
const SOURCE_REPOSITORY_URL = "https://github.com/paperclipai/paperclip";
const SOURCE_VERSION_RE = /\+\d+\.git\.([0-9a-f]{7,40})(?:\.dirty)?$/i;
interface SidebarAccountMenuProps {
deploymentMode?: DeploymentMode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
serverGit?: ServerGitInfo;
version?: string | null;
/** Contextual navigation occupies a full sidebar even if the saved global nav mode is collapsed. */
forceExpanded?: boolean;
}
@ -70,11 +65,6 @@ function deriveUserSlug(name: string | null | undefined, email: string | null |
return "me";
}
function sourceVersionSha(version: string): string | null {
const sourceVersion = version.match(SOURCE_VERSION_RE);
return sourceVersion?.[1] ?? null;
}
function MenuAction({ label, description, icon: Icon, onClick, href, external = false }: MenuActionProps) {
const className =
"flex w-full items-start gap-3 rounded-xl px-3 py-3 text-left transition-colors hover:bg-accent/60";
@ -118,8 +108,6 @@ export function SidebarAccountMenu({
deploymentMode,
open: controlledOpen,
onOpenChange,
serverGit,
version,
forceExpanded = false,
}: SidebarAccountMenuProps) {
const [internalOpen, setInternalOpen] = useState(false);
@ -138,15 +126,8 @@ export function SidebarAccountMenu({
const displayName = session?.user.name?.trim() || "Board";
const secondaryLabel =
session?.user.email?.trim() || (deploymentMode === "authenticated" ? "Signed in" : "Local workspace board");
const accountBadge = deploymentMode === "authenticated" ? "Account" : "Local";
const initials = deriveInitials(displayName);
const profileHref = `/u/${deriveUserSlug(session?.user.name, session?.user.email, session?.user.id)}`;
const sourceSha = version ? sourceVersionSha(version) : null;
const sourceFullSha =
sourceSha && serverGit?.available && serverGit.fullSha.toLowerCase().startsWith(sourceSha.toLowerCase())
? serverGit.fullSha
: sourceSha;
const sourceBranch = sourceSha && serverGit?.available ? serverGit.branchName : null;
function closeNavigationChrome() {
setOpen(false);
@ -193,40 +174,8 @@ export function SidebarAccountMenu({
</Avatar>
</div>
<div className="min-w-0 flex-1 pt-1">
<div className="flex items-center gap-2">
<h2 className="truncate text-base font-semibold text-foreground">{displayName}</h2>
<Badge variant="ghost" className="bg-accent text-(length:--text-nano) font-semibold uppercase tracking-wide text-muted-foreground">
{accountBadge}
</Badge>
</div>
<h2 className="truncate text-base font-semibold text-foreground">{displayName}</h2>
<p className="truncate text-sm text-muted-foreground">{secondaryLabel}</p>
{sourceSha && sourceFullSha ? (
<div className="mt-1 text-xs text-muted-foreground">
{sourceBranch ? (
<a
href={`${SOURCE_REPOSITORY_URL}/tree/${encodeURIComponent(sourceBranch)}`}
target="_blank"
rel="noreferrer"
className="block truncate transition-colors hover:text-foreground"
>
{sourceBranch}
</a>
) : null}
<p>
Paperclip{" "}
<a
href={`${SOURCE_REPOSITORY_URL}/commit/${sourceFullSha}`}
target="_blank"
rel="noreferrer"
className="transition-colors hover:text-foreground"
>
{sourceSha.slice(0, 7)}
</a>
</p>
</div>
) : version ? (
<p className="mt-1 text-xs text-muted-foreground">Paperclip v{version}</p>
) : null}
</div>
</div>

View File

@ -11,6 +11,15 @@ interface PanelContextValue {
closePanel: () => void;
setPanelVisible: (visible: boolean) => void;
togglePanelVisible: () => void;
/**
* One-shot maximize request (LOOA-2181): deep links with `viewer=full` ask
* the resizable panel host to open maximized. The request stays pending
* until the host consumes it (the panel may not be mounted yet when the
* deep link routes), so consumers must clear it after acting.
*/
panelMaximizeRequested: boolean;
requestPanelMaximize: () => void;
clearPanelMaximizeRequest: () => void;
}
const PanelContext = createContext<PanelContextValue | null>(null);
@ -36,6 +45,15 @@ export function PanelProvider({ children }: { children: ReactNode }) {
const [panelContent, setPanelContent] = useState<ReactNode | null>(null);
const [panelContentMode, setPanelContentMode] = useState<SidePanelContentMode>("padded");
const [panelVisible, setPanelVisibleState] = useState(readPreference);
const [panelMaximizeRequested, setPanelMaximizeRequested] = useState(false);
const requestPanelMaximize = useCallback(() => {
setPanelMaximizeRequested(true);
}, []);
const clearPanelMaximizeRequest = useCallback(() => {
setPanelMaximizeRequested(false);
}, []);
const openPanel = useCallback((content: ReactNode, options?: { contentMode?: SidePanelContentMode }) => {
setPanelContent(content);
@ -62,7 +80,18 @@ export function PanelProvider({ children }: { children: ReactNode }) {
return (
<PanelContext.Provider
value={{ panelContent, panelContentMode, panelVisible, openPanel, closePanel, setPanelVisible, togglePanelVisible }}
value={{
panelContent,
panelContentMode,
panelVisible,
openPanel,
closePanel,
setPanelVisible,
togglePanelVisible,
panelMaximizeRequested,
requestPanelMaximize,
clearPanelMaximizeRequest,
}}
>
{children}
</PanelContext.Provider>

View File

@ -15,6 +15,7 @@ describe("parseDocumentAnnotationHash", () => {
documentKey: "plan",
threadId: null,
commentId: null,
viewer: null,
});
});
@ -25,6 +26,25 @@ describe("parseDocumentAnnotationHash", () => {
documentKey: "plan",
threadId: "t1",
commentId: "c2",
viewer: null,
});
});
it("parses the viewer=full request", () => {
expect(parseDocumentAnnotationHash("#document-direction-package&viewer=full")).toEqual({
documentKey: "direction-package",
threadId: null,
commentId: null,
viewer: "full",
});
});
it("ignores unknown viewer values", () => {
expect(parseDocumentAnnotationHash("#document-plan&viewer=huge")).toEqual({
documentKey: "plan",
threadId: null,
commentId: null,
viewer: null,
});
});
@ -33,6 +53,7 @@ describe("parseDocumentAnnotationHash", () => {
documentKey: "my notes",
threadId: "abc",
commentId: null,
viewer: null,
});
});
@ -60,8 +81,24 @@ describe("buildDocumentAnnotationHash", () => {
).toBe("#document-plan&thread=t1&comment=c2");
});
it("includes the viewer request", () => {
expect(
buildDocumentAnnotationHash({
documentKey: "direction-package",
threadId: null,
commentId: null,
viewer: "full",
}),
).toBe("#document-direction-package&viewer=full");
});
it("survives a round trip", () => {
const target = { documentKey: "plan-2", threadId: "t-abc", commentId: "c-xyz" };
const target = {
documentKey: "plan-2",
threadId: "t-abc",
commentId: "c-xyz",
viewer: "full" as const,
};
expect(parseDocumentAnnotationHash(buildDocumentAnnotationHash(target))).toEqual(target);
});
});

View File

@ -2,6 +2,13 @@ export interface DocumentAnnotationHashTarget {
documentKey: string;
threadId: string | null;
commentId: string | null;
/**
* `viewer=full` (LOOA-2181): external deep links — e.g. the Slack gateway's
* "Open task" button on an approval card — request the document opened in
* the maximized (full-size) properties pane, skipping the manual
* open-pane → Artifacts → open → maximize click chain.
*/
viewer: "full" | null;
}
const DOCUMENT_HASH_PREFIX = "#document-";
@ -25,13 +32,17 @@ export function parseDocumentAnnotationHash(hash: string): DocumentAnnotationHas
documentKey,
threadId: threadId && threadId.length > 0 ? threadId : null,
commentId: commentId && commentId.length > 0 ? commentId : null,
viewer: params.get("viewer") === "full" ? "full" : null,
};
}
export function buildDocumentAnnotationHash(target: DocumentAnnotationHashTarget): string {
export function buildDocumentAnnotationHash(
target: Omit<DocumentAnnotationHashTarget, "viewer"> & { viewer?: "full" | null },
): string {
const params = new URLSearchParams();
if (target.threadId) params.set("thread", target.threadId);
if (target.commentId) params.set("comment", target.commentId);
if (target.viewer) params.set("viewer", target.viewer);
const qs = params.toString();
const encodedKey = encodeURIComponent(target.documentKey);
return qs ? `${DOCUMENT_HASH_PREFIX}${encodedKey}&${qs}` : `${DOCUMENT_HASH_PREFIX}${encodedKey}`;

View File

@ -13,6 +13,7 @@ describe("resolveIssueDocumentDeepLink", () => {
kind: "properties-pane",
tab: "plans",
documentKey: "plan",
maximize: false,
});
});
@ -21,6 +22,22 @@ describe("resolveIssueDocumentDeepLink", () => {
kind: "properties-pane",
tab: "document",
documentKey: "qa evidence",
maximize: false,
});
});
it("requests the maximized pane for viewer=full deep links", () => {
expect(resolveIssueDocumentDeepLink("#document-direction-package&viewer=full")).toEqual({
kind: "properties-pane",
tab: "document",
documentKey: "direction-package",
maximize: true,
});
expect(resolveIssueDocumentDeepLink("#document-plan&viewer=full")).toEqual({
kind: "properties-pane",
tab: "plans",
documentKey: "plan",
maximize: true,
});
});

View File

@ -3,14 +3,16 @@ import { parseDocumentAnnotationHash } from "./document-annotation-hash";
export type IssueDocumentDeepLinkRoute =
| { kind: "continuation-summary" }
| { kind: "properties-pane"; tab: "plans"; documentKey: "plan" }
| { kind: "properties-pane"; tab: "document"; documentKey: string };
| { kind: "properties-pane"; tab: "plans"; documentKey: "plan"; maximize: boolean }
| { kind: "properties-pane"; tab: "document"; documentKey: string; maximize: boolean };
/**
* Maps an issue document hash to the surface that owns that document.
*
* The continuation summary remains in the activity/handoff surface, the plan
* keeps its dedicated pane tab, and every other document opens in its own tab.
* `viewer=full` (LOOA-2181) additionally requests the maximized pane so
* external links (Slack approval cards) land on a full-size reading surface.
*/
export function resolveIssueDocumentDeepLink(hash: string): IssueDocumentDeepLinkRoute | null {
const target = parseDocumentAnnotationHash(hash);
@ -19,8 +21,9 @@ export function resolveIssueDocumentDeepLink(hash: string): IssueDocumentDeepLin
if (target.documentKey === ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY) {
return { kind: "continuation-summary" };
}
const maximize = target.viewer === "full";
if (target.documentKey === "plan") {
return { kind: "properties-pane", tab: "plans", documentKey: "plan" };
return { kind: "properties-pane", tab: "plans", documentKey: "plan", maximize };
}
return { kind: "properties-pane", tab: "document", documentKey: target.documentKey };
return { kind: "properties-pane", tab: "document", documentKey: target.documentKey, maximize };
}

View File

@ -1,6 +1,7 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import {
buildPermissionsForTrustPreset,
clearSingleLowTrustBoundaryTarget,
getLowTrustBoundary,
getSingleLowTrustBoundaryTarget,
@ -10,6 +11,19 @@ import {
} from "./trust-policy-ui";
describe("trust-policy-ui low-trust boundary helpers", () => {
it("drops hire authority when switching to the low-trust preset", () => {
const demoted = buildPermissionsForTrustPreset(
{ canCreateAgents: true, canCreateSkills: true },
"low_trust_review",
);
expect(demoted.canCreateAgents).toBe(false);
expect(demoted.canCreateSkills).toBe(true);
const restored = buildPermissionsForTrustPreset(demoted, "standard");
expect(restored.canCreateAgents).toBe(false);
expect(restored.trustPreset).toBe("standard");
});
it("writes one project boundary with mode and company id", () => {
const permissions = setSingleLowTrustBoundaryTarget(null, "company-1", {
type: "project",

View File

@ -52,6 +52,10 @@ export function buildPermissionsForTrustPreset(
if (preset === LOW_TRUST_REVIEW_PRESET) {
return {
...current,
// Hire authority is default-on for standard-trust agents, so demoting
// to low-trust must drop it rather than carry the old value forward.
// Operators can re-enable it explicitly afterwards.
canCreateAgents: false,
trustPreset: LOW_TRUST_REVIEW_PRESET,
authorizationPolicy: buildLowTrustReviewPolicy(current.authorizationPolicy),
};

View File

@ -118,7 +118,10 @@ const mockLocation = vi.hoisted(() => ({
const mockOpenPanel = vi.hoisted(() => vi.fn());
const mockClosePanel = vi.hoisted(() => vi.fn());
const mockSetPanelVisible = vi.hoisted(() => vi.fn());
const mockRequestPanelMaximize = vi.hoisted(() => vi.fn());
const mockClearPanelMaximizeRequest = vi.hoisted(() => vi.fn());
const mockPanelState = vi.hoisted(() => ({ panelVisible: true }));
const mockRouteParams = vi.hoisted(() => ({ issueId: "PAP-1" }));
const mockSidebarState = vi.hoisted(() => ({ isMobile: false }));
const mockIssuePropertiesRender = vi.hoisted(() => vi.fn());
const mockTaskSidePanelRender = vi.hoisted(() => vi.fn());
@ -216,7 +219,7 @@ vi.mock("@/lib/router", () => ({
useLocation: () => mockLocation,
useNavigate: () => mockNavigate,
useNavigationType: () => "PUSH",
useParams: () => ({ issueId: "PAP-1" }),
useParams: () => ({ ...mockRouteParams }),
}));
vi.mock("../context/CompanyContext", () => ({
@ -266,6 +269,8 @@ vi.mock("../context/PanelContext", () => ({
closePanel: mockClosePanel,
panelVisible: mockPanelState.panelVisible,
setPanelVisible: mockSetPanelVisible,
requestPanelMaximize: mockRequestPanelMaximize,
clearPanelMaximizeRequest: mockClearPanelMaximizeRequest,
}),
}));
@ -1347,6 +1352,8 @@ describe("IssueDetail", () => {
mockOpenPanel.mockClear();
mockClosePanel.mockClear();
mockSetPanelVisible.mockClear();
mockRequestPanelMaximize.mockClear();
mockClearPanelMaximizeRequest.mockClear();
mockSetBreadcrumbPanelControl.mockClear();
mockSetMobileToolbar.mockClear();
mockIssuePropertiesRender.mockClear();
@ -1364,6 +1371,7 @@ describe("IssueDetail", () => {
mockLocation.search = "";
mockLocation.hash = "";
mockLocation.state = null;
mockRouteParams.issueId = "PAP-1";
});
afterEach(async () => {
@ -1802,6 +1810,84 @@ describe("IssueDetail", () => {
});
});
it("maximizes the desktop pane once per viewer=full deep link", async () => {
mockPanelState.panelVisible = false;
mockLocation.hash = "#document-qa-evidence&viewer=full";
mockIssuesApi.get.mockResolvedValue(createIssue());
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await waitForAssertion(() => {
expect(mockSetPanelVisible).toHaveBeenCalledWith(true);
expect(mockRequestPanelMaximize).toHaveBeenCalledTimes(1);
});
// Replaying the same hash (same-page link click) reopens the document but
// must not re-maximize a pane the user may have deliberately restored.
const link = document.createElement("a");
link.href = "#document-qa-evidence&viewer=full";
link.textContent = "QA evidence";
container.appendChild(link);
await act(async () => link.click());
expect(mockRequestPanelMaximize).toHaveBeenCalledTimes(1);
// Ending the deep link drops the pending request and re-arms the guard.
mockLocation.hash = "";
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await waitForAssertion(() => {
expect(mockClearPanelMaximizeRequest).toHaveBeenCalled();
});
});
it("re-maximizes when navigating to another issue with an identical viewer=full hash", async () => {
mockPanelState.panelVisible = false;
mockLocation.hash = "#document-qa-evidence&viewer=full";
mockIssuesApi.get.mockResolvedValue(createIssue());
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await waitForAssertion(() => {
expect(mockRequestPanelMaximize).toHaveBeenCalledTimes(1);
});
// Navigate to a sibling issue whose URL carries the same document hash.
// IssueDetail stays mounted; the destination pane must still maximize.
mockRouteParams.issueId = "PAP-2";
mockLocation.pathname = "/issues/PAP-2";
mockIssuesApi.get.mockResolvedValue(
createIssue({ id: "issue-2", identifier: "PAP-2" }),
);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await waitForAssertion(() => {
expect(mockRequestPanelMaximize).toHaveBeenCalledTimes(2);
});
});
it("opens the mobile properties sheet for a document deep link", async () => {
mockSidebarState.isMobile = true;
mockLocation.hash = "#document-qa-evidence";

View File

@ -2670,7 +2670,14 @@ export function IssueDetail() {
? "mx-auto w-full max-w-(--tc-shell-max-w)"
: undefined;
const { openNewIssue } = useDialogActions();
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel();
const {
openPanel,
closePanel,
panelVisible,
setPanelVisible,
requestPanelMaximize,
clearPanelMaximizeRequest,
} = usePanel();
const {
setBreadcrumbs,
setBreadcrumbToolbar,
@ -5398,6 +5405,12 @@ export function IssueDetail() {
sourceBreadcrumb.href,
]);
// One maximize request per issue + `viewer=full` hash: routing re-runs
// whenever a callback dependency changes identity, and re-requesting then
// would re-maximize a pane the user deliberately restored. The key carries
// the issue param so navigating to another issue with an identical hash
// still maximizes the destination pane.
const lastMaximizeRequestKeyRef = useRef<string | null>(null);
const routeIssueDocumentDeepLink = useCallback(
(hash: string) => {
const route = resolveIssueDocumentDeepLink(hash);
@ -5421,6 +5434,16 @@ export function IssueDetail() {
setPanelBeforePlanOverrideIssueId(issue.id);
}
setPanelVisible(true);
// `viewer=full` (LOOA-2181): external links (Slack approval cards)
// land with the pane maximized. Mobile uses the sheet, which is
// already full-screen, so the request is desktop-only.
if (route.maximize) {
const requestKey = `${issueId ?? ""}::${hash}`;
if (lastMaximizeRequestKeyRef.current !== requestKey) {
lastMaximizeRequestKeyRef.current = requestKey;
requestPanelMaximize();
}
}
}
const targetIssueId = issue?.id ?? issueId ?? "";
setDocumentDeepLink((current) => ({
@ -5438,6 +5461,7 @@ export function IssueDetail() {
issue?.id,
issueId,
setPanelVisible,
requestPanelMaximize,
suppressPanelUntilPlan,
taskChatShellEnabled,
],
@ -5446,8 +5470,21 @@ export function IssueDetail() {
useEffect(() => {
if (!routeIssueDocumentDeepLink(location.hash)) {
setDocumentDeepLink(null);
// The deep link ended (hash cleared or issue changed): drop any
// maximize request the panel never consumed so it cannot maximize a
// later, unrelated panel, and re-arm for the next viewer=full hash.
lastMaximizeRequestKeyRef.current = null;
clearPanelMaximizeRequest();
}
}, [issueId, location.hash, routeIssueDocumentDeepLink]);
}, [issueId, location.hash, routeIssueDocumentDeepLink, clearPanelMaximizeRequest]);
// Leaving the issue page entirely also ends the deep link's lifetime.
useEffect(
() => () => {
clearPanelMaximizeRequest();
},
[clearPanelMaximizeRequest],
);
// React Router does not emit a location update when the user clicks a link
// whose hash is already current. Capture that repeated intent so a manually

View File

@ -256,7 +256,6 @@ function NavigationLayoutStories() {
deploymentMode="authenticated"
open
onOpenChange={() => undefined}
version="0.3.1"
/>
</div>
</div>