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:
commit
57355011cb
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"]');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -256,7 +256,6 @@ function NavigationLayoutStories() {
|
|||
deploymentMode="authenticated"
|
||||
open
|
||||
onOpenChange={() => undefined}
|
||||
version="0.3.1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue