diff --git a/doc/CLI.md b/doc/CLI.md index 971648137e..0cdd67107b 100644 --- a/doc/CLI.md +++ b/doc/CLI.md @@ -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 ` to attach it to a specific issue. During Paperclip diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index 2be813ca6f..4a9416d14a 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -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(), diff --git a/server/src/__tests__/agent-permissions-service.test.ts b/server/src/__tests__/agent-permissions-service.test.ts index ddd7dc9b22..6f8e37e300 100644 --- a/server/src/__tests__/agent-permissions-service.test.ts +++ b/server/src/__tests__/agent-permissions-service.test.ts @@ -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", () => { diff --git a/server/src/__tests__/authorization-service.test.ts b/server/src/__tests__/authorization-service.test.ts index 871aabb27e..7a303268f4 100644 --- a/server/src/__tests__/authorization-service.test.ts +++ b/server/src/__tests__/authorization-service.test.ts @@ -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"); diff --git a/server/src/services/agent-permissions.ts b/server/src/services/agent-permissions.ts index 49eba9aa4e..e628a62ec5 100644 --- a/server/src/services/agent-permissions.ts +++ b/server/src/services/agent-permissions.ts @@ -1,28 +1,68 @@ +import { LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared"; + export type NormalizedAgentPermissions = Record & { canCreateAgents: boolean; canCreateSkills: boolean; }; -export function defaultPermissionsForRole(role: string): NormalizedAgentPermissions { +function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : 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; - const preserved = { ...record }; return { - ...preserved, + ...record, canCreateAgents: typeof record.canCreateAgents === "boolean" ? record.canCreateAgents diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index 86dee4a2a2..f785397746 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -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; 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)) diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts index 6a20e38a3c..c6cad46937 100644 --- a/server/src/services/authorization.ts +++ b/server/src/services/authorization.ts @@ -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).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", diff --git a/ui/src/components/Layout.production.tsx b/ui/src/components/Layout.production.tsx index 2903267e57..12621fca3b 100644 --- a/ui/src/components/Layout.production.tsx +++ b/ui/src/components/Layout.production.tsx @@ -604,8 +604,6 @@ export function Layout() { ) : ( @@ -624,8 +622,6 @@ export function Layout() { )} diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index b308eb394f..99b7fba771 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -659,8 +659,6 @@ export function Layout() { @@ -684,8 +682,6 @@ export function Layout() { diff --git a/ui/src/components/PropertiesPanel.test.tsx b/ui/src/components/PropertiesPanel.test.tsx index d30119222b..e478d654f6 100644 --- a/ui/src/components/PropertiesPanel.test.tsx +++ b/ui/src/components/PropertiesPanel.test.tsx @@ -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('[aria-label="Close side panel"]'); diff --git a/ui/src/components/PropertiesPanel.tsx b/ui/src/components/PropertiesPanel.tsx index 26bbd2ca32..783ec1dd39 100644 --- a/ui/src/components/PropertiesPanel.tsx +++ b/ui/src/components/PropertiesPanel.tsx @@ -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) => { if (event.target !== asideRef.current || event.propertyName !== "left") return; diff --git a/ui/src/components/SidebarAccountMenu.production.tsx b/ui/src/components/SidebarAccountMenu.production.tsx index b592387638..c136d4ea2f 100644 --- a/ui/src/components/SidebarAccountMenu.production.tsx +++ b/ui/src/components/SidebarAccountMenu.production.tsx @@ -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({
-
-

{displayName}

- - {accountBadge} - -
+

{displayName}

{secondaryLabel}

- {sourceSha && sourceFullSha ? ( -
- {sourceBranch ? ( - - {sourceBranch} - - ) : null} -

- Paperclip{" "} - - {sourceSha.slice(0, 7)} - -

-
- ) : version ? ( -

Paperclip v{version}

- ) : null}
diff --git a/ui/src/components/SidebarAccountMenu.test.tsx b/ui/src/components/SidebarAccountMenu.test.tsx index b883af6637..5c4ffe0c02 100644 --- a/ui/src/components/SidebarAccountMenu.test.tsx +++ b/ui/src/components/SidebarAccountMenu.test.tsx @@ -196,10 +196,7 @@ describe("SidebarAccountMenu", () => { root.render( - + , ); @@ -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( - - - - - , - ); - }); - 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(); - }); - }); }); diff --git a/ui/src/components/SidebarAccountMenu.tsx b/ui/src/components/SidebarAccountMenu.tsx index caec0bbd30..87c2897d51 100644 --- a/ui/src/components/SidebarAccountMenu.tsx +++ b/ui/src/components/SidebarAccountMenu.tsx @@ -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({
-
-

{displayName}

- - {accountBadge} - -
+

{displayName}

{secondaryLabel}

- {sourceSha && sourceFullSha ? ( -
- {sourceBranch ? ( - - {sourceBranch} - - ) : null} -

- Paperclip{" "} - - {sourceSha.slice(0, 7)} - -

-
- ) : version ? ( -

Paperclip v{version}

- ) : null}
diff --git a/ui/src/context/PanelContext.tsx b/ui/src/context/PanelContext.tsx index fcb79e9245..1893c47705 100644 --- a/ui/src/context/PanelContext.tsx +++ b/ui/src/context/PanelContext.tsx @@ -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(null); @@ -36,6 +45,15 @@ export function PanelProvider({ children }: { children: ReactNode }) { const [panelContent, setPanelContent] = useState(null); const [panelContentMode, setPanelContentMode] = useState("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 ( {children} diff --git a/ui/src/lib/document-annotation-hash.test.ts b/ui/src/lib/document-annotation-hash.test.ts index c954bcce1e..7899d0e955 100644 --- a/ui/src/lib/document-annotation-hash.test.ts +++ b/ui/src/lib/document-annotation-hash.test.ts @@ -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); }); }); diff --git a/ui/src/lib/document-annotation-hash.ts b/ui/src/lib/document-annotation-hash.ts index 1b0b859e91..dd58b53c54 100644 --- a/ui/src/lib/document-annotation-hash.ts +++ b/ui/src/lib/document-annotation-hash.ts @@ -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 & { 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}`; diff --git a/ui/src/lib/issue-document-deep-link.test.ts b/ui/src/lib/issue-document-deep-link.test.ts index fca00b3ae1..a5bfb60f98 100644 --- a/ui/src/lib/issue-document-deep-link.test.ts +++ b/ui/src/lib/issue-document-deep-link.test.ts @@ -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, }); }); diff --git a/ui/src/lib/issue-document-deep-link.ts b/ui/src/lib/issue-document-deep-link.ts index cfee5e2a01..19132d1f5f 100644 --- a/ui/src/lib/issue-document-deep-link.ts +++ b/ui/src/lib/issue-document-deep-link.ts @@ -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 }; } diff --git a/ui/src/lib/trust-policy-ui.test.ts b/ui/src/lib/trust-policy-ui.test.ts index c142c6bb6f..a791c62a15 100644 --- a/ui/src/lib/trust-policy-ui.test.ts +++ b/ui/src/lib/trust-policy-ui.test.ts @@ -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", diff --git a/ui/src/lib/trust-policy-ui.ts b/ui/src/lib/trust-policy-ui.ts index f2442435d8..86ee6d5f47 100644 --- a/ui/src/lib/trust-policy-ui.ts +++ b/ui/src/lib/trust-policy-ui.ts @@ -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), }; diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index 479cae38b7..9abb6d8d09 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -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( + + + , + ); + }); + + 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( + + + , + ); + }); + 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( + + + , + ); + }); + + 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( + + + , + ); + }); + + 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"; diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 6a29babbe1..bad72401fd 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -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(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 diff --git a/ui/storybook/stories/navigation-layout.stories.tsx b/ui/storybook/stories/navigation-layout.stories.tsx index c78bdfb879..4eca73816f 100644 --- a/ui/storybook/stories/navigation-layout.stories.tsx +++ b/ui/storybook/stories/navigation-layout.stories.tsx @@ -256,7 +256,6 @@ function NavigationLayoutStories() { deploymentMode="authenticated" open onOpenChange={() => undefined} - version="0.3.1" />