Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/sequence-activation-bars.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@sketchi/cli": minor
---

Sequence diagrams from `sketchi create` and `sketchi generate` now draw activation bars. A message of type `return` answers the latest open call between the two participants, and the callee's lifeline shows a bar from that call to its return. Re-entrant calls nest, and calls nobody answers draw no bar. Messages attach to the bars in the PNG and Excalidraw exports.

Activation bars use ids under `<participant>:lifeline:`, so a participant id that starts with another participant's id followed by `:lifeline` (for example `api:lifeline:v2` next to `api`) is now rejected. Before, only an exact `api:lifeline` collided. The repair hint is now "Rename the participant so its id does not start with another participant id followed by :lifeline."
2 changes: 2 additions & 0 deletions apps/playground/src/server/codemode/mcp-docs/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,7 @@ export const catalog: CatalogEntry[] = [
"sequence",
"participant",
"lifeline",
"activation",
"message",
"chronology",
"interaction",
Expand All @@ -442,6 +443,7 @@ export const catalog: CatalogEntry[] = [
"buildSequenceDiagram accepts a semantic SequenceDiagramSpec with ordered participants and chronological messages.",
"Use a sequence diagram when time-ordered interactions between actors or systems are the main idea. Use buildFlowchart for decisions and process paths; use buildMindmap for nested topic hierarchy.",
"Participant order is preserved left-to-right. Message order is preserved top-to-bottom.",
'A message with type "return" from the callee back to its caller answers the latest open call between them; Sketchi draws the callee\'s activation bar from that call to its return, nesting re-entrant calls. Calls nobody answers draw no bar.',
"Every message source and target must reference a declared participant id. Self-referential messages are rejected with structured issues and repair hints.",
'Request envelope: { spec: SequenceDiagramSpec, options?: { artifactFormats?: ["scene", "excalidraw", "png"], inlineArtifacts?: ["scene", "excalidraw"], minQualityScore?: number } }.',
"Request scene, Excalidraw, and PNG for full artifact delivery; inline Excalidraw when the harness needs importable JSON in the response.",
Expand Down

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion packages/diagram/agent/src/lib/code-mode/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/sp
import {
CANVAS_LIMITS,
CANVAS_NODE_ICON,
CANVAS_RENDERER_ROLES,
CANVAS_SPEC_VERSION,
SEQUENCE_MESSAGE_STYLES,
SEQUENCE_MESSAGE_TYPES,
Expand Down Expand Up @@ -906,7 +907,7 @@ export class NodeSceneElement extends Schema.Class<NodeSceneElement>("NodeSceneE
nodeId: RequiredNonEmptyString,
kind: optionalContract(NonEmptyString),
icon: optionalContract(CanvasNodeIcon).pipe(Schema.mutableKey),
rendererRole: optionalContract(literals(["sequence-lifeline"])),
rendererRole: optionalContract(literals(CANVAS_RENDERER_ROLES)),
shape: literals(["rectangle", "ellipse", "diamond", "circle", "polygon"]).pipe(
Schema.mutableKey,
),
Expand Down
95 changes: 95 additions & 0 deletions packages/diagram/agent/src/lib/code-mode/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1809,6 +1809,101 @@ describe("Code Mode runtime", () => {
});
});

it("keeps activation bars and their bound messages through an inline-scene patch", async () => {
const runtime = createTestRuntime();
const spec = checkoutSequenceSpec();
const built = await runtime.buildSequenceDiagram({
spec: {
...spec,
messages: [
...spec.messages.slice(0, 2),
{ source: "payments", target: "store", label: "Approved", type: "return" },
{ source: "store", target: "customer", label: "Receipt", type: "return" },
],
},
options: { artifactFormats: ["scene", "excalidraw"], inlineArtifacts: ["scene"] },
});
if (!built.ok) throw new Error("Expected accepted sequence diagram.");
const sourceScene = parseInlineScene(
built.artifact.formats.find(({ format }) => format === "scene")?.inline,
);
const bars = (scene: typeof sourceScene) =>
scene.elements.flatMap((element) =>
element.type === "node" && element.rendererRole === "sequence-activation"
? [element.nodeId]
: [],
);
expect(bars(sourceScene)).toEqual([
"store:lifeline:activation:message-1-customer-store",
"payments:lifeline:activation:message-2-store-payments",
]);

const patched = await runtime.applyDiagramPatch({
source: { scene: sourceScene },
operations: [{ op: "setDefaultStyle", style: {} }],
options: { artifactFormats: ["scene", "excalidraw"], inlineArtifacts: ["scene"] },
});
expectPatchOk(patched);
const patchedScene = parseInlineScene(
patched.artifact.formats.find(({ format }) => format === "scene")?.inline,
);
expect(bars(patchedScene)).toEqual(bars(sourceScene));
expect(
patchedScene.elements.find(
(element) => element.type === "arrow" && element.edgeId === "message-1-customer-store",
),
).toMatchObject({ targetNodeId: "store:lifeline:activation:message-1-customer-store" });
});

it("keeps doubly nested activation bars through an inline-scene patch", async () => {
const runtime = createTestRuntime();
const built = await runtime.buildSequenceDiagram({
spec: {
title: "Deep nesting",
participants: [
{ id: "a", label: "A" },
{ id: "b", label: "B" },
{ id: "c", label: "C" },
],
messages: [
{ id: "q1", source: "a", target: "b", label: "First" },
{ id: "q2", source: "c", target: "b", label: "Second" },
{ id: "q3", source: "a", target: "b", label: "Third" },
{ id: "r3", source: "b", target: "a", label: "Third done", type: "return" },
{ id: "r2", source: "b", target: "c", label: "Second done", type: "return" },
{ id: "r1", source: "b", target: "a", label: "First done", type: "return" },
],
},
options: { artifactFormats: ["scene", "excalidraw"], inlineArtifacts: ["scene"] },
});
if (!built.ok) throw new Error("Expected accepted sequence diagram.");
const sourceScene = parseInlineScene(
built.artifact.formats.find(({ format }) => format === "scene")?.inline,
);

const patched = await runtime.applyDiagramPatch({
source: { scene: sourceScene },
operations: [{ op: "setDefaultStyle", style: {} }],
options: { artifactFormats: ["scene", "excalidraw"], inlineArtifacts: ["scene"] },
});
expectPatchOk(patched);
expect(patched.issues).toEqual([]);
const patchedScene = parseInlineScene(
patched.artifact.formats.find(({ format }) => format === "scene")?.inline,
);
expect(
patchedScene.elements.flatMap((element) =>
element.type === "node" && element.nodeId.includes(":activation:")
? [[element.nodeId, element.rendererRole]]
: [],
),
).toEqual([
["b:lifeline:activation:q1", "sequence-activation"],
["b:lifeline:activation:q2", "sequence-activation"],
["b:lifeline:activation:q3", "sequence-activation"],
]);
});

it("styles a crossing sequence through an inline-scene patch", async () => {
const runtime = createTestRuntime();
const built = await runtime.buildSequenceDiagram({
Expand Down
7 changes: 5 additions & 2 deletions packages/diagram/agent/src/lib/code-mode/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
import {
renderIntermediateDiagram,
renderSequenceDiagram,
isStructurallyValidSequenceActivation,
isStructurallyValidSequenceLifeline,
type RenderedDiagramScene,
type ScenePoint,
Expand Down Expand Up @@ -1087,8 +1088,10 @@ function normalizePatchableScene(scene: PatchableScene): RenderedDiagramScene |
...(element.roughness !== undefined ? { roughness: element.roughness } : {}),
...(element.strokeStyle ? { strokeStyle: element.strokeStyle } : {}),
...(element.strokeWidth !== undefined ? { strokeWidth: element.strokeWidth } : {}),
...(element.rendererRole === "sequence-lifeline" &&
isStructurallyValidSequenceLifeline(scene, element)
...((element.rendererRole === "sequence-lifeline" &&
isStructurallyValidSequenceLifeline(scene, element)) ||
(element.rendererRole === "sequence-activation" &&
isStructurallyValidSequenceActivation(scene, element))
? { rendererRole: element.rendererRole }
: {}),
...(element.textColor ? { textColor: element.textColor } : {}),
Expand Down
28 changes: 21 additions & 7 deletions packages/diagram/core/src/canvas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,25 @@ export interface CanvasIconAsset {
readonly svg: string;
}

/**
* Structural shapes the sequence renderer derives: lifelines and activation
* bars. They carry no label or icon and are not obstacles for messages.
*/
export const CANVAS_RENDERER_ROLES = ["sequence-lifeline", "sequence-activation"] as const;
export type CanvasRendererRole = (typeof CANVAS_RENDERER_ROLES)[number];

const RENDERER_ROLE_NAMES: Readonly<Record<CanvasRendererRole, string>> = {
"sequence-activation": "Sequence activation",
"sequence-lifeline": "Sequence lifeline",
};

export interface CanvasShapeElement extends CanvasElementComposition, CanvasStrokeStyleFields {
readonly type: "node";
readonly id: string;
readonly nodeId: string;
readonly kind?: string;
readonly icon?: CanvasNodeIcon;
readonly rendererRole?: "sequence-lifeline";
readonly rendererRole?: CanvasRendererRole;
readonly shape: CanvasShapeKind;
readonly points?: [CanvasPoint, CanvasPoint, CanvasPoint, ...CanvasPoint[]];
readonly textColor?: string;
Expand Down Expand Up @@ -396,7 +408,8 @@ export interface DroppedCanvasIcon {
readonly elementId: string;
readonly index: number;
/**
* unavailable: no catalog asset; lifeline: sequence lifelines carry no logo;
* unavailable: no catalog asset; lifeline: sequence lifelines and activation
* bars carry no logo;
* no_room: the label would no longer fit; budget: per-scene bytes spent.
*/
readonly reason: "budget" | "lifeline" | "no_room" | "unavailable";
Expand Down Expand Up @@ -466,7 +479,8 @@ function nodeLabelOverflows(
* Embed one asset per referenced icon slug and remove icons that cannot be
* drawn, so an icon never fails a build and every exported scene renders
* without a catalog or network. Any authored `icons` map is replaced. An icon
* is dropped when its asset is unavailable, it sits on a sequence lifeline, or
* is dropped when its asset is unavailable, it sits on a sequence lifeline or
* activation bar, or
* its label would no longer fit; icons past the per-scene byte budget are
* dropped in element order, which keeps the result deterministic.
*/
Expand Down Expand Up @@ -494,7 +508,7 @@ export function embedCanvasIcons(
) {
return drop("unavailable");
}
if (element.rendererRole === "sequence-lifeline") return drop("lifeline");
if (element.rendererRole) return drop("lifeline");
if (element.shape !== "polygon" && isVisible(element)) {
const label = boundLabels.get(element.id)?.element;
if (
Expand Down Expand Up @@ -533,11 +547,11 @@ function validateNodeIcon(
if (!icon) return [];
const path = `elements[${index}].icon`;
const issues: CanvasValidationIssue[] = [];
if (element.rendererRole === "sequence-lifeline") {
if (element.rendererRole) {
issues.push({
code: "invalid_icon",
elementId: element.id,
message: `Sequence lifeline "${element.id}" cannot carry an icon.`,
message: `${RENDERER_ROLE_NAMES[element.rendererRole]} "${element.id}" cannot carry an icon.`,
path,
});
}
Expand Down Expand Up @@ -715,7 +729,7 @@ export function getCanvasValidationIssues(canvas: CanvasSpec): CanvasValidationI
}
if (element.type === "node" && element.shape !== "polygon" && isLayerVisible(element)) {
const boundLabel = nodeLabelsByContainerId.get(element.id);
if (boundLabel || element.rendererRole !== "sequence-lifeline") {
if (boundLabel || element.rendererRole === undefined) {
if (nodeLabelOverflows(element, boundLabel?.element, element.icon)) {
issues.push({
code: "label_overflow",
Expand Down
Loading
Loading