From aa62fe0c8fa06e4838d8e9139e3328e3323feac7 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Tue, 4 Aug 2026 09:35:17 +0100 Subject: [PATCH 1/4] fix(canvas): register system.canvases HogQL table The desktop channel home board lists a channel's canvases with `SELECT ... FROM system.canvases WHERE channel_id = ...`, but the canvases remodel never registered that table in the HogQL schema, so the query fails with "Unknown table" and every home board silently renders "No canvases yet." Register `system.canvases` over `posthog_canvas`, scope-gated like `system.tasks`, excluding soft-deleted rows to mirror the REST API's default filter. Generated-By: PostHog Code Task-Id: d75a71da-b662-4101-af83-0588664849c2 --- posthog/hogql/database/schema/system.py | 62 ++++ .../schema/test/test_system_tables.py | 43 +++ .../test/__snapshots__/test_database.ambr | 298 ++++++++++++++++++ 3 files changed, 403 insertions(+) diff --git a/posthog/hogql/database/schema/system.py b/posthog/hogql/database/schema/system.py index 710f73df4d5b..78bf3b67cb5a 100644 --- a/posthog/hogql/database/schema/system.py +++ b/posthog/hogql/database/schema/system.py @@ -2188,6 +2188,67 @@ def ticket_assignment_join(join_to_add: LazyJoinToAdd, context: HogQLContext, no ) +canvases: PostgresTable = PostgresTable( + name="canvases", + postgres_table_name="posthog_canvas", + access_scope="canvas", + # Mirror the REST API's default filter: soft-deleted canvases are not exposed. + predicates=[parse_expr("deleted != true")], + description="Canvases (agent-built sandboxed browser apps, filed into channels); one row per canvas (soft-deleted canvases are excluded).", + fields={ + "id": StringDatabaseField(name="id", description="Canvas UUID."), + "team_id": IntegerDatabaseField(name="team_id"), + "channel_id": StringDatabaseField( + name="channel_id", description="Channel the canvas is filed into (tasks Channel UUID)." + ), + "name": StringDatabaseField(name="name", description="Canvas name."), + "template_id": StringDatabaseField( + name="template_id", description="Template the canvas was created from, e.g. 'freeform'." + ), + "context": StringDatabaseField( + name="context", description="Author-written context (markdown) passed to generation tasks." + ), + "generation_task_id": StringDatabaseField( + name="generation_task_id", + nullable=True, + description="Task currently generating this canvas; joins to tasks.id.", + ), + "pinned_at": DateTimeDatabaseField( + name="pinned_at", + nullable=True, + description="When the canvas was pinned to its channel; NULL if not pinned.", + ), + "_is_home": BooleanDatabaseField(name="is_home", hidden=True), + "is_home": ExpressionField( + name="is_home", + expr=ast.Call(name="toInt", args=[ast.Field(chain=["_is_home"])]), + description="1 if this is the channel's home canvas (the board shown when the channel opens), 0 otherwise.", + ), + "current_source_version_id": StringDatabaseField( + name="current_source_version_id", + nullable=True, + description="The canvas's head source version; NULL until the first publish.", + ), + "published_build_id": StringDatabaseField( + name="published_build_id", + nullable=True, + description="Build whose artifact the canvas app currently renders; NULL until the first successful build.", + ), + "created_by_id": IntegerDatabaseField( + name="created_by_id", nullable=True, description="User who created the canvas." + ), + "_deleted": BooleanDatabaseField(name="deleted", hidden=True), + "deleted": ExpressionField( + name="deleted", + expr=ast.Call(name="toInt", args=[ast.Field(chain=["_deleted"])]), + description="1 if the canvas has been deleted, 0 otherwise (always 0 here due to the table filter).", + ), + "created_at": DateTimeDatabaseField(name="created_at", description="When the canvas was created."), + "updated_at": DateTimeDatabaseField(name="updated_at", description="When the canvas was last updated."), + }, +) + + tags: PostgresTable = PostgresTable( name="tags", postgres_table_name="posthog_tag", @@ -2229,6 +2290,7 @@ class SystemTables(TableNode): name="business_knowledge_documents", table=business_knowledge_documents ), "business_knowledge_sources": TableNode(name="business_knowledge_sources", table=business_knowledge_sources), + "canvases": TableNode(name="canvases", table=canvases), "cohort_calculation_history": TableNode(name="cohort_calculation_history", table=cohort_calculation_history), "cohorts": TableNode(name="cohorts", table=cohorts), "custom_property_definitions": TableNode(name="custom_property_definitions", table=custom_property_definitions), diff --git a/posthog/hogql/database/schema/test/test_system_tables.py b/posthog/hogql/database/schema/test/test_system_tables.py index 58e9bcd20dc2..5b0a16599ec9 100644 --- a/posthog/hogql/database/schema/test/test_system_tables.py +++ b/posthog/hogql/database/schema/test/test_system_tables.py @@ -581,6 +581,15 @@ def _create_task(team: Team, label: str): ) +def _create_canvas(team: Team, label: str): + Channel = apps.get_model("tasks", "Channel") + Canvas = apps.get_model("canvas", "Canvas") + + with team_scope(team.pk): + channel = Channel.objects.create(team=team, name=f"channel_for_canvas_{label}") + return Canvas.objects.create(team=team, channel=channel, name=f"canvas_{label}") + + def _create_task_run(team: Team, label: str): Task = apps.get_model("tasks", "Task") TaskRun = apps.get_model("tasks", "TaskRun") @@ -681,6 +690,7 @@ def _create_business_knowledge_chunk(team: Team, label: str): ("business_knowledge_chunks", _create_business_knowledge_chunk), ("business_knowledge_documents", _create_business_knowledge_document), ("business_knowledge_sources", _create_business_knowledge_source), + ("canvases", _create_canvas), ("cohorts", _create_cohort), ("cohort_calculation_history", _create_cohort_calculation_history), ("custom_property_definitions", _create_custom_property_definition), @@ -820,6 +830,39 @@ def test_internal_environments_excluded(self): assert str(internal_env.pk) not in ids +class TestSystemTablesCanvasDeletedExclusion(BaseTest): + """Verify the canvases system table excludes soft-deleted canvases, + mirroring the REST API's default filter.""" + + def test_generated_sql_includes_deleted_predicate(self): + db = Database.create_for(team=self.team, user=self.user) + context = HogQLContext(team_id=self.team.pk, enable_select_queries=True, database=db) + query, _ = prepare_and_print_ast(parse_select("SELECT id FROM system.canvases"), context, dialect="clickhouse") + assert "system__canvases.deleted" in query + assert f"equals(system__canvases.team_id, {self.team.pk})" in query + + +class TestSystemTablesCanvasDeletedExclusionIsolation(NonAtomicBaseTest): + """End-to-end check that soft-deleted canvases are never returned via HogQL.""" + + CLASS_DATA_LEVEL_SETUP = False + + def test_deleted_canvases_excluded(self): + Channel = apps.get_model("tasks", "Channel") + Canvas = apps.get_model("canvas", "Canvas") + + with team_scope(self.team.pk): + channel = Channel.objects.create(team=self.team, name="canvas-exclusion-channel") + live_canvas = Canvas.objects.create(team=self.team, channel=channel, name="live") + deleted_canvas = Canvas.objects.create(team=self.team, channel=channel, name="deleted", deleted=True) + + response = execute_hogql_query("SELECT id FROM system.canvases", team=self.team, user=self.user) + ids = {str(row[0]) for row in response.results} + + assert str(live_canvas.pk) in ids + assert str(deleted_canvas.pk) not in ids + + class TestSystemTablesTaskInternalExclusion(BaseTest): """Verify the tasks system table excludes internal tasks (signals pipeline, etc.) mirroring the REST API's default filter.""" diff --git a/posthog/hogql/database/test/__snapshots__/test_database.ambr b/posthog/hogql/database/test/__snapshots__/test_database.ambr index 35a63323cd34..8401e3979af6 100644 --- a/posthog/hogql/database/test/__snapshots__/test_database.ambr +++ b/posthog/hogql/database/test/__snapshots__/test_database.ambr @@ -3932,6 +3932,155 @@ "row_count": null, "type": "system" }, + "system.canvases": { + "certification": null, + "fields": { + "id": { + "chain": null, + "fields": null, + "hogql_value": "id", + "id": null, + "name": "id", + "schema_valid": true, + "table": null, + "type": "string" + }, + "channel_id": { + "chain": null, + "fields": null, + "hogql_value": "channel_id", + "id": null, + "name": "channel_id", + "schema_valid": true, + "table": null, + "type": "string" + }, + "name": { + "chain": null, + "fields": null, + "hogql_value": "name", + "id": null, + "name": "name", + "schema_valid": true, + "table": null, + "type": "string" + }, + "template_id": { + "chain": null, + "fields": null, + "hogql_value": "template_id", + "id": null, + "name": "template_id", + "schema_valid": true, + "table": null, + "type": "string" + }, + "context": { + "chain": null, + "fields": null, + "hogql_value": "context", + "id": null, + "name": "context", + "schema_valid": true, + "table": null, + "type": "string" + }, + "generation_task_id": { + "chain": null, + "fields": null, + "hogql_value": "generation_task_id", + "id": null, + "name": "generation_task_id", + "schema_valid": true, + "table": null, + "type": "string" + }, + "pinned_at": { + "chain": null, + "fields": null, + "hogql_value": "pinned_at", + "id": null, + "name": "pinned_at", + "schema_valid": true, + "table": null, + "type": "datetime" + }, + "is_home": { + "chain": null, + "fields": null, + "hogql_value": "is_home", + "id": null, + "name": "is_home", + "schema_valid": true, + "table": null, + "type": "integer" + }, + "current_source_version_id": { + "chain": null, + "fields": null, + "hogql_value": "current_source_version_id", + "id": null, + "name": "current_source_version_id", + "schema_valid": true, + "table": null, + "type": "string" + }, + "published_build_id": { + "chain": null, + "fields": null, + "hogql_value": "published_build_id", + "id": null, + "name": "published_build_id", + "schema_valid": true, + "table": null, + "type": "string" + }, + "created_by_id": { + "chain": null, + "fields": null, + "hogql_value": "created_by_id", + "id": null, + "name": "created_by_id", + "schema_valid": true, + "table": null, + "type": "integer" + }, + "deleted": { + "chain": null, + "fields": null, + "hogql_value": "deleted", + "id": null, + "name": "deleted", + "schema_valid": true, + "table": null, + "type": "integer" + }, + "created_at": { + "chain": null, + "fields": null, + "hogql_value": "created_at", + "id": null, + "name": "created_at", + "schema_valid": true, + "table": null, + "type": "datetime" + }, + "updated_at": { + "chain": null, + "fields": null, + "hogql_value": "updated_at", + "id": null, + "name": "updated_at", + "schema_valid": true, + "table": null, + "type": "datetime" + } + }, + "id": "system.canvases", + "name": "system.canvases", + "row_count": null, + "type": "system" + }, "system.cohort_calculation_history": { "certification": null, "fields": { @@ -12822,6 +12971,155 @@ "row_count": null, "type": "system" }, + "system.canvases": { + "certification": null, + "fields": { + "id": { + "chain": null, + "fields": null, + "hogql_value": "id", + "id": null, + "name": "id", + "schema_valid": true, + "table": null, + "type": "string" + }, + "channel_id": { + "chain": null, + "fields": null, + "hogql_value": "channel_id", + "id": null, + "name": "channel_id", + "schema_valid": true, + "table": null, + "type": "string" + }, + "name": { + "chain": null, + "fields": null, + "hogql_value": "name", + "id": null, + "name": "name", + "schema_valid": true, + "table": null, + "type": "string" + }, + "template_id": { + "chain": null, + "fields": null, + "hogql_value": "template_id", + "id": null, + "name": "template_id", + "schema_valid": true, + "table": null, + "type": "string" + }, + "context": { + "chain": null, + "fields": null, + "hogql_value": "context", + "id": null, + "name": "context", + "schema_valid": true, + "table": null, + "type": "string" + }, + "generation_task_id": { + "chain": null, + "fields": null, + "hogql_value": "generation_task_id", + "id": null, + "name": "generation_task_id", + "schema_valid": true, + "table": null, + "type": "string" + }, + "pinned_at": { + "chain": null, + "fields": null, + "hogql_value": "pinned_at", + "id": null, + "name": "pinned_at", + "schema_valid": true, + "table": null, + "type": "datetime" + }, + "is_home": { + "chain": null, + "fields": null, + "hogql_value": "is_home", + "id": null, + "name": "is_home", + "schema_valid": true, + "table": null, + "type": "integer" + }, + "current_source_version_id": { + "chain": null, + "fields": null, + "hogql_value": "current_source_version_id", + "id": null, + "name": "current_source_version_id", + "schema_valid": true, + "table": null, + "type": "string" + }, + "published_build_id": { + "chain": null, + "fields": null, + "hogql_value": "published_build_id", + "id": null, + "name": "published_build_id", + "schema_valid": true, + "table": null, + "type": "string" + }, + "created_by_id": { + "chain": null, + "fields": null, + "hogql_value": "created_by_id", + "id": null, + "name": "created_by_id", + "schema_valid": true, + "table": null, + "type": "integer" + }, + "deleted": { + "chain": null, + "fields": null, + "hogql_value": "deleted", + "id": null, + "name": "deleted", + "schema_valid": true, + "table": null, + "type": "integer" + }, + "created_at": { + "chain": null, + "fields": null, + "hogql_value": "created_at", + "id": null, + "name": "created_at", + "schema_valid": true, + "table": null, + "type": "datetime" + }, + "updated_at": { + "chain": null, + "fields": null, + "hogql_value": "updated_at", + "id": null, + "name": "updated_at", + "schema_valid": true, + "table": null, + "type": "datetime" + } + }, + "id": "system.canvases", + "name": "system.canvases", + "row_count": null, + "type": "system" + }, "system.cohort_calculation_history": { "certification": null, "fields": { From 5433aae1688db17420d4bf1b35beb174ba398bcd Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Tue, 4 Aug 2026 11:00:34 +0100 Subject: [PATCH 2/4] feat(canvas): retire the home-canvas concept The channel landing page is a native view already, and the only navigation into a home canvas had no callers, so the auto-created seeded home board was vestigial: UI shipped as per-channel data that can't be updated fleet-wide, makes channel-open depend on the build pipeline, and hides query failures as "No canvases yet." (how the missing system.canvases table read as data loss). Remove the client machinery (ensure/reset/seed, tRPC routes, isHome plumbing), the backend is_home field, filter, and unique constraint (constraint dropped for real; column removed from Django state only with a db-side default first), and soft-delete the system-generated home boards. User-created canvases are untouched. Generated OpenAPI/MCP types and snapshots regenerated. Generated-By: PostHog Code Task-Id: d75a71da-b662-4101-af83-0588664849c2 --- posthog/hogql/database/schema/system.py | 6 - .../test/__snapshots__/test_database.ambr | 20 - .../0007_soft_delete_home_canvases.py | 24 + .../migrations/0008_remove_home_canvas.py | 33 ++ .../backend/migrations/max_migration.txt | 2 +- products/canvas/backend/models.py | 9 - .../backend/presentation/serializers.py | 6 - products/canvas/backend/presentation/views.py | 40 +- .../canvas/backend/tests/test_canvas_api.py | 13 - .../canvas/frontend/generated/api.schemas.ts | 7 - products/canvas/frontend/generated/api.zod.ts | 6 - .../core/src/canvas/channelItems.test.ts | 1 - .../core/src/canvas/dashboardSchemas.ts | 6 - .../core/src/canvas/dashboardsService.test.ts | 206 +------ .../core/src/canvas/dashboardsService.ts | 524 +----------------- .../packages/core/src/canvas/services.ts | 5 - .../src/routers/dashboards.router.ts | 17 - .../canvas/freeform/FreeformCanvasView.tsx | 22 +- .../canvas/freeform/useCanvasNavigation.ts | 39 ++ .../canvas/freeform/useHomeCanvasView.ts | 106 ---- .../features/canvas/hooks/useDashboards.ts | 51 -- services/mcp/src/api/generated.ts | 7 - services/mcp/src/generated/canvas/api.ts | 7 - services/mcp/src/tools/generated/canvas.ts | 4 - .../tool-schemas/canvas-create.json | 5 - .../tool-schemas/canvas-list.json | 4 - 26 files changed, 112 insertions(+), 1058 deletions(-) create mode 100644 products/canvas/backend/migrations/0007_soft_delete_home_canvases.py create mode 100644 products/canvas/backend/migrations/0008_remove_home_canvas.py create mode 100644 products/desktop/packages/ui/src/features/canvas/freeform/useCanvasNavigation.ts delete mode 100644 products/desktop/packages/ui/src/features/canvas/freeform/useHomeCanvasView.ts diff --git a/posthog/hogql/database/schema/system.py b/posthog/hogql/database/schema/system.py index 78bf3b67cb5a..b8036dc476c9 100644 --- a/posthog/hogql/database/schema/system.py +++ b/posthog/hogql/database/schema/system.py @@ -2218,12 +2218,6 @@ def ticket_assignment_join(join_to_add: LazyJoinToAdd, context: HogQLContext, no nullable=True, description="When the canvas was pinned to its channel; NULL if not pinned.", ), - "_is_home": BooleanDatabaseField(name="is_home", hidden=True), - "is_home": ExpressionField( - name="is_home", - expr=ast.Call(name="toInt", args=[ast.Field(chain=["_is_home"])]), - description="1 if this is the channel's home canvas (the board shown when the channel opens), 0 otherwise.", - ), "current_source_version_id": StringDatabaseField( name="current_source_version_id", nullable=True, diff --git a/posthog/hogql/database/test/__snapshots__/test_database.ambr b/posthog/hogql/database/test/__snapshots__/test_database.ambr index 8401e3979af6..b02aed4968db 100644 --- a/posthog/hogql/database/test/__snapshots__/test_database.ambr +++ b/posthog/hogql/database/test/__snapshots__/test_database.ambr @@ -4005,16 +4005,6 @@ "table": null, "type": "datetime" }, - "is_home": { - "chain": null, - "fields": null, - "hogql_value": "is_home", - "id": null, - "name": "is_home", - "schema_valid": true, - "table": null, - "type": "integer" - }, "current_source_version_id": { "chain": null, "fields": null, @@ -13044,16 +13034,6 @@ "table": null, "type": "datetime" }, - "is_home": { - "chain": null, - "fields": null, - "hogql_value": "is_home", - "id": null, - "name": "is_home", - "schema_valid": true, - "table": null, - "type": "integer" - }, "current_source_version_id": { "chain": null, "fields": null, diff --git a/products/canvas/backend/migrations/0007_soft_delete_home_canvases.py b/products/canvas/backend/migrations/0007_soft_delete_home_canvases.py new file mode 100644 index 000000000000..8ef59c68ab84 --- /dev/null +++ b/products/canvas/backend/migrations/0007_soft_delete_home_canvases.py @@ -0,0 +1,24 @@ +"""Soft-delete the auto-created channel home boards. + +The channel home is a native client view now, so the seeded home-board +canvases are retired. Soft-delete (recoverable) because they were +system-generated directory listings, not user content; user-created canvases +are untouched. The `is_home` machinery itself is removed in 0008. +""" + +from django.db import migrations + + +def soft_delete_home_canvases(apps, schema_editor): + Canvas = apps.get_model("canvas", "Canvas") + Canvas.objects.filter(is_home=True, deleted=False).update(deleted=True) + + +class Migration(migrations.Migration): + dependencies = [ + ("canvas", "0006_require_build_enqueued_at"), + ] + + operations = [ + migrations.RunPython(soft_delete_home_canvases, migrations.RunPython.noop, elidable=False), + ] diff --git a/products/canvas/backend/migrations/0008_remove_home_canvas.py b/products/canvas/backend/migrations/0008_remove_home_canvas.py new file mode 100644 index 000000000000..38760936bf2f --- /dev/null +++ b/products/canvas/backend/migrations/0008_remove_home_canvas.py @@ -0,0 +1,33 @@ +"""Retire the home-canvas concept (schema side; 0007 soft-deleted the rows). + +The partial unique constraint is dropped for real; the column itself is +removed from Django state only (per safe-migration policy) and given a +database-side default first, so inserts that no longer mention it keep +working. A later migration may drop the column physically. +""" + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("canvas", "0007_soft_delete_home_canvases"), + ] + + operations = [ + migrations.RemoveConstraint( + model_name="canvas", + name="unique_home_canvas_per_channel", + ), + migrations.AlterField( + model_name="canvas", + name="is_home", + field=models.BooleanField(default=False, db_default=False), + ), + migrations.SeparateDatabaseAndState( + database_operations=[], + state_operations=[ + migrations.RemoveField(model_name="canvas", name="is_home"), + ], + ), + ] diff --git a/products/canvas/backend/migrations/max_migration.txt b/products/canvas/backend/migrations/max_migration.txt index d0da8dfa0911..4b4ca0731778 100644 --- a/products/canvas/backend/migrations/max_migration.txt +++ b/products/canvas/backend/migrations/max_migration.txt @@ -1 +1 @@ -0006_require_build_enqueued_at +0008_remove_home_canvas diff --git a/products/canvas/backend/models.py b/products/canvas/backend/models.py index a079fa728d2f..92491e4b12c9 100644 --- a/products/canvas/backend/models.py +++ b/products/canvas/backend/models.py @@ -32,8 +32,6 @@ class Canvas(TeamScopedRootMixin, UUIDModel): generation_task_id = models.UUIDField(null=True, blank=True) # Set when the canvas is pinned to its channel (shared across users). pinned_at = models.DateTimeField(null=True, blank=True) - # A channel's home canvas is the board shown when the channel opens. - is_home = models.BooleanField(default=False) current_source_version = models.ForeignKey( "canvas.CanvasSourceVersion", on_delete=models.SET_NULL, null=True, blank=True, related_name="+" @@ -56,13 +54,6 @@ class Canvas(TeamScopedRootMixin, UUIDModel): class Meta: db_table = "posthog_canvas" indexes = [models.Index(fields=["channel", "-created_at"], name="canvas_channel_recency")] - constraints = [ - models.UniqueConstraint( - fields=["channel"], - condition=Q(is_home=True, deleted=False), - name="unique_home_canvas_per_channel", - ) - ] class CanvasSourceVersion(TeamScopedRootMixin, UUIDModel): diff --git a/products/canvas/backend/presentation/serializers.py b/products/canvas/backend/presentation/serializers.py index cb198cec4e9a..babd99b9c52f 100644 --- a/products/canvas/backend/presentation/serializers.py +++ b/products/canvas/backend/presentation/serializers.py @@ -41,7 +41,6 @@ class Meta: "generation_task_id", "pinned", "pinned_at", - "is_home", "current_version_id", "published_build_id", "created_by", @@ -67,11 +66,6 @@ class CanvasCreateSerializer(serializers.Serializer): template_id = serializers.CharField( required=False, default="freeform", max_length=64, help_text="Canvas template identifier." ) - is_home = serializers.BooleanField( - required=False, - default=False, - help_text="Create the canvas as the channel's home board (at most one per channel).", - ) class CanvasUpdateSerializer(serializers.Serializer): diff --git a/products/canvas/backend/presentation/views.py b/products/canvas/backend/presentation/views.py index 11773f6cd002..7f6a9e564ed5 100644 --- a/products/canvas/backend/presentation/views.py +++ b/products/canvas/backend/presentation/views.py @@ -3,7 +3,6 @@ from django.conf import settings from django.core.exceptions import ValidationError as DjangoValidationError -from django.db import IntegrityError, transaction from django.db.models import QuerySet from django.utils import timezone @@ -19,7 +18,6 @@ from posthog.models.user import User from posthog.storage.object_storage import ObjectStorageError from posthog.temporal.oauth import SANDBOX_OAUTH_APP_CLIENT_IDS -from posthog.utils import str_to_bool from products.canvas.backend import build_service from products.canvas.backend.models import Canvas, CanvasBuild, CanvasSourceVersion @@ -111,7 +109,6 @@ class CanvasViewSet(TeamAndOrgViewSetMixin, viewsets.ModelViewSet): OpenApiParameter( "channel", OpenApiTypes.UUID, required=False, description="Only return canvases in this channel." ), - OpenApiParameter("is_home", bool, required=False, description="Filter by channel-home status."), ] ) def list(self, request: Request, *args: Any, **kwargs: Any) -> Response: @@ -134,9 +131,6 @@ def safely_get_queryset(self, queryset: QuerySet) -> QuerySet: except ValueError: return queryset.none() queryset = queryset.filter(channel_id=channel_id) - is_home = self.request.query_params.get("is_home") - if is_home is not None: - queryset = queryset.filter(is_home=str_to_bool(is_home)) return queryset.order_by("-created_at") @extend_schema( @@ -154,28 +148,18 @@ def create(self, request: Request, *args: Any, **kwargs: Any) -> Response: # someone else's personal channel must be refused here too. if not tasks_facade.channel_exists(self.team_id, channel_id, user.id if user else None): return Response({"detail": "Channel not found in this team."}, status=status.HTTP_400_BAD_REQUEST) - try: - # Savepoint so losing the is_home uniqueness race doesn't poison - # the request's transaction. - with transaction.atomic(): - canvas = Canvas.objects.create( - team_id=self.team_id, - channel_id=channel_id, - name=payload.validated_data["name"], - template_id=payload.validated_data["template_id"], - is_home=payload.validated_data["is_home"], - created_by=user, - # A sandbox-created canvas is its task's deliverable: bind - # the two at birth so the client can show the run on the - # canvas and nest the task under it — composer-initiated - # generations have no client-side create to record it. - generation_task_id=self._sandbox_task_id(request), - ) - except IntegrityError: - return Response( - {"detail": "This channel already has a home canvas.", "code": "home_canvas_exists"}, - status=status.HTTP_409_CONFLICT, - ) + canvas = Canvas.objects.create( + team_id=self.team_id, + channel_id=channel_id, + name=payload.validated_data["name"], + template_id=payload.validated_data["template_id"], + created_by=user, + # A sandbox-created canvas is its task's deliverable: bind + # the two at birth so the client can show the run on the + # canvas and nest the task under it — composer-initiated + # generations have no client-side create to record it. + generation_task_id=self._sandbox_task_id(request), + ) return Response(CanvasSerializer(canvas).data, status=status.HTTP_201_CREATED) @extend_schema( diff --git a/products/canvas/backend/tests/test_canvas_api.py b/products/canvas/backend/tests/test_canvas_api.py index eb56dc082e50..1d595661b700 100644 --- a/products/canvas/backend/tests/test_canvas_api.py +++ b/products/canvas/backend/tests/test_canvas_api.py @@ -150,19 +150,6 @@ def test_create_rejects_unknown_channel(self): ) assert response.status_code == status.HTTP_400_BAD_REQUEST - def test_home_canvas_is_unique_per_channel(self): - self._create_canvas(name="Home", is_home=True) - response = self.client.post( - f"/api/projects/{self.team.id}/canvases/", - {"name": "Home 2", "channel_id": str(self.channel.id), "is_home": True}, - format="json", - ) - assert response.status_code == status.HTTP_409_CONFLICT - assert response.json()["code"] == "home_canvas_exists" - - response = self.client.get(f"/api/projects/{self.team.id}/canvases/?is_home=true") - assert [row["name"] for row in response.json()["results"]] == ["Home"] - def test_partial_update_metadata(self): canvas_id = self._create_canvas() response = self.client.patch( diff --git a/products/canvas/frontend/generated/api.schemas.ts b/products/canvas/frontend/generated/api.schemas.ts index 0f27a14afe0a..bf56caca63b6 100644 --- a/products/canvas/frontend/generated/api.schemas.ts +++ b/products/canvas/frontend/generated/api.schemas.ts @@ -79,7 +79,6 @@ export interface CanvasApi { readonly pinned: boolean /** @nullable */ readonly pinned_at: string | null - readonly is_home: boolean /** * Id of the live source version — pass as expected_current_version_id on publish. Null before the first publish. * @nullable @@ -120,8 +119,6 @@ export interface CanvasCreateApi { * @maxLength 64 */ template_id?: string - /** Create the canvas as the channel's home board (at most one per channel). */ - is_home?: boolean } /** @@ -625,10 +622,6 @@ export type CanvasesListParams = { * Only return canvases in this channel. */ channel?: string - /** - * Filter by channel-home status. - */ - is_home?: boolean /** * Number of results to return per page. */ diff --git a/products/canvas/frontend/generated/api.zod.ts b/products/canvas/frontend/generated/api.zod.ts index 220c83277d97..84718bed20d5 100644 --- a/products/canvas/frontend/generated/api.zod.ts +++ b/products/canvas/frontend/generated/api.zod.ts @@ -17,8 +17,6 @@ export const canvasesCreateBodyNameMax = 400 export const canvasesCreateBodyTemplateIdDefault = `freeform` export const canvasesCreateBodyTemplateIdMax = 64 -export const canvasesCreateBodyIsHomeDefault = false - export const CanvasesCreateBody = /* @__PURE__ */ zod .object({ name: zod.string().max(canvasesCreateBodyNameMax).describe('Display name for the canvas.'), @@ -28,10 +26,6 @@ export const CanvasesCreateBody = /* @__PURE__ */ zod .max(canvasesCreateBodyTemplateIdMax) .default(canvasesCreateBodyTemplateIdDefault) .describe('Canvas template identifier.'), - is_home: zod - .boolean() - .default(canvasesCreateBodyIsHomeDefault) - .describe("Create the canvas as the channel's home board (at most one per channel)."), }) .describe('Payload for creating a new, empty canvas in a channel.') diff --git a/products/desktop/packages/core/src/canvas/channelItems.test.ts b/products/desktop/packages/core/src/canvas/channelItems.test.ts index cabe9efc8acd..bbb4a9a6a2f7 100644 --- a/products/desktop/packages/core/src/canvas/channelItems.test.ts +++ b/products/desktop/packages/core/src/canvas/channelItems.test.ts @@ -32,7 +32,6 @@ function canvas(over: Partial = {}): DashboardRecord { name: "Canvas", templateId: "freeform", context: "", - isHome: false, createdAt: 0, updatedAt: 1_000, ...over, diff --git a/products/desktop/packages/core/src/canvas/dashboardSchemas.ts b/products/desktop/packages/core/src/canvas/dashboardSchemas.ts index 48fb57947680..2d5fb978f1cb 100644 --- a/products/desktop/packages/core/src/canvas/dashboardSchemas.ts +++ b/products/desktop/packages/core/src/canvas/dashboardSchemas.ts @@ -23,8 +23,6 @@ export const dashboardRecordSchema = z.object({ updatedAt: z.number(), // Epoch ms the canvas was pinned to its channel; absent = not pinned. pinnedAt: z.number().optional(), - // Whether this is the channel's home board (at most one per channel). - isHome: z.boolean().default(false), // Head source version — pass as expected_current_version_id when publishing. currentVersionId: z.string().nullish(), // The live (last successful, still-eligible) build. @@ -95,10 +93,6 @@ export const saveContextInput = z.object({ context: z.string(), }); -export const ensureHomeCanvasInput = z.object({ - channelId: z.string().min(1), -}); - // Rename a canvas (its display title). export const renameDashboardInput = z.object({ id: z.string().min(1), diff --git a/products/desktop/packages/core/src/canvas/dashboardsService.test.ts b/products/desktop/packages/core/src/canvas/dashboardsService.test.ts index f2060ea381a8..9d13b489af6b 100644 --- a/products/desktop/packages/core/src/canvas/dashboardsService.test.ts +++ b/products/desktop/packages/core/src/canvas/dashboardsService.test.ts @@ -1,7 +1,6 @@ -import { transform } from "esbuild"; import { describe, expect, it, vi } from "vitest"; import { DashboardsService } from "./dashboardsService"; -import { type ProjectApiClient, ProjectApiError } from "./projectApiClient"; +import type { ProjectApiClient } from "./projectApiClient"; // A canvas as the PostHog canvases API returns it. function apiCanvas(overrides: Record = {}) { @@ -13,7 +12,6 @@ function apiCanvas(overrides: Record = {}) { context: "", generation_task_id: null, pinned_at: null, - is_home: false, current_version_id: "v1", published_build_id: null, created_by: { first_name: "Ada", last_name: "L", email: "ada@x.com" }, @@ -71,213 +69,11 @@ describe("DashboardsService.list", () => { name: "Revenue board", createdBy: "Ada L", currentVersionId: "v1", - isHome: false, }); expect(rows[0].createdAt).toBe(Date.parse("2026-07-01T00:00:00Z")); }); }); -describe("DashboardsService.ensureHomeCanvas", () => { - it("returns an existing seeded home canvas without creating another", async () => { - const home = apiCanvas({ - id: "home-1", - is_home: true, - current_version_id: "v9", - }); - const { api, calls } = fakeApi({ - "canvases/?channel=chan-1&is_home=true": [home], - }); - const service = new DashboardsService(api); - - const record = await service.ensureHomeCanvas("chan-1"); - - expect(record.id).toBe("home-1"); - expect( - calls.every((call) => !call.init || call.init.method === undefined), - ).toBe(true); - }); - - it("creates and publish-seeds a home canvas when the channel has none", async () => { - const created = apiCanvas({ - id: "home-1", - is_home: true, - current_version_id: null, - }); - let published: Record | null = null; - const { api } = fakeApi({ - "canvases/?channel=chan-1&is_home=true": [], - "canvases/home-1/publish/": (init?: RequestInit) => { - published = JSON.parse(String(init?.body)); - return { current_version_id: "v1" }; - }, - "canvases/home-1/": apiCanvas({ - id: "home-1", - is_home: true, - current_version_id: "v1", - }), - "canvases/": created, - }); - const service = new DashboardsService(api); - - const record = await service.ensureHomeCanvas("chan-1"); - - expect(record.currentVersionId).toBe("v1"); - expect(published).not.toBeNull(); - const payload = published as unknown as { - project: { - files: Record; - capabilities: { posthog: { inlineQueries: boolean } }; - }; - expected_current_version_id: string | null; - }; - // The board queries system tables ad hoc, so the capability must be - // declared or view mode rejects every data request. - expect(payload.project.capabilities.posthog.inlineQueries).toBe(true); - expect(payload.expected_current_version_id).toBeNull(); - expect(payload.project.files["src/canvas.tsx"]).toContain( - "system.canvases", - ); - expect(payload.project.files["src/canvas.tsx"]).toContain("system.tasks"); - }); - - it("seeds source that transpiles as valid TSX", async () => { - const { api } = fakeApi({ - "canvases/?channel=chan-1&is_home=true": [], - "canvases/home-1/publish/": { current_version_id: "v1" }, - "canvases/home-1/": apiCanvas({ - id: "home-1", - is_home: true, - current_version_id: "v1", - }), - "canvases/": apiCanvas({ - id: "home-1", - is_home: true, - current_version_id: null, - }), - }); - const service = new DashboardsService(api); - - await service.ensureHomeCanvas("chan-1"); - - const publish = (api.json as ReturnType).mock.calls.find( - ([path]) => String(path).endsWith("/publish/"), - ); - expect(publish).toBeDefined(); - const body = JSON.parse(String(publish?.[2]?.body)) as { - project: { files: Record }; - }; - await expect( - transform(body.project.files["src/canvas.tsx"], { loader: "tsx" }), - ).resolves.toBeDefined(); - }); -}); - -describe("DashboardsService.ensureHomeCanvas races", () => { - it("reuses the winner's canvas when create loses the is_home uniqueness race (409)", async () => { - let lookups = 0; - const { api } = fakeApi({ - // First lookup: none. After the 409, the winner's home canvas exists. - "canvases/?channel=chan-1&is_home=true": () => { - lookups += 1; - return lookups === 1 - ? [] - : [ - apiCanvas({ - id: "home-winner", - is_home: true, - current_version_id: "v1", - }), - ]; - }, - "canvases/": () => { - throw new ProjectApiError("Failed to create canvas (409)", 409); - }, - }); - const service = new DashboardsService(api); - - const record = await service.ensureHomeCanvas("chan-1"); - - expect(record.id).toBe("home-winner"); - }); - - it("rethrows a non-409 create failure instead of masking it as a race", async () => { - const { api } = fakeApi({ - "canvases/?channel=chan-1&is_home=true": [], - "canvases/": () => { - throw new ProjectApiError("Failed to create canvas (403)", 403); - }, - }); - const service = new DashboardsService(api); - - await expect(service.ensureHomeCanvas("chan-1")).rejects.toMatchObject({ - status: 403, - }); - }); - - it("retries the seed publish once on a 409 version conflict", async () => { - const home = apiCanvas({ - id: "home-1", - is_home: true, - current_version_id: null, - }); - let publishCalls = 0; - const { api } = fakeApi({ - "canvases/?channel=chan-1&is_home=true": [home], - "canvases/home-1/publish/": (init?: RequestInit) => { - publishCalls += 1; - if (publishCalls === 1) { - throw new ProjectApiError("Failed to seed home canvas (409)", 409); - } - const body = JSON.parse(String(init?.body)); - return { current_version_id: body.expected_current_version_id ?? "v1" }; - }, - "canvases/home-1/": apiCanvas({ - id: "home-1", - is_home: true, - current_version_id: "v-fresh", - }), - }); - const service = new DashboardsService(api); - - const record = await service.ensureHomeCanvas("chan-1"); - - expect(publishCalls).toBe(2); - expect(record.id).toBe("home-1"); - }); -}); - -describe("DashboardsService.resetHomeCanvas", () => { - it("publishes a fresh default guarded on the current head", async () => { - const home = apiCanvas({ - id: "home-1", - is_home: true, - current_version_id: "v3", - }); - let published: Record | null = null; - const { api } = fakeApi({ - "canvases/?channel=chan-1&is_home=true": [home], - "canvases/home-1/publish/": (init?: RequestInit) => { - published = JSON.parse(String(init?.body)); - return { current_version_id: "v4" }; - }, - "canvases/home-1/": apiCanvas({ - id: "home-1", - is_home: true, - current_version_id: "v4", - }), - }); - const service = new DashboardsService(api); - - const record = await service.resetHomeCanvas("chan-1"); - - expect(record.currentVersionId).toBe("v4"); - expect( - (published as unknown as { expected_current_version_id: string | null }) - ?.expected_current_version_id, - ).toBe("v3"); - }); -}); - describe("DashboardsService.getBuilds", () => { it("normalizes the lifecycle payload", async () => { const { api } = fakeApi({ diff --git a/products/desktop/packages/core/src/canvas/dashboardsService.ts b/products/desktop/packages/core/src/canvas/dashboardsService.ts index 32887daf9adf..2a6e186e75db 100644 --- a/products/desktop/packages/core/src/canvas/dashboardsService.ts +++ b/products/desktop/packages/core/src/canvas/dashboardsService.ts @@ -1,9 +1,3 @@ -import { - CANVAS_COMPONENT_PATH, - CANVAS_ENTRY_HTML, - CANVAS_PLATFORM_MANIFEST, - CANVAS_SOURCE_SCHEMA_VERSION, -} from "@posthog/shared"; import { inject, injectable } from "inversify"; import { type CanvasBuildActionInput, @@ -18,29 +12,7 @@ import type { DashboardRecord, } from "./dashboardSchemas"; import { FREEFORM_TEMPLATE_ID } from "./freeformSchemas"; -import { - apiErrorStatus, - PROJECT_API_CLIENT, - type ProjectApiClient, -} from "./projectApiClient"; - -// Display name (canvas h1) of a channel's auto-created home canvas. -const HOME_CANVAS_NAME = "Home"; - -// The entry shell for a client-authored single-file project (the home canvas -// seed): the runtime mounts the default export of the canvas component file. -const SINGLE_FILE_INDEX_HTML = ` - - - - - - -
- - - -`; +import { PROJECT_API_CLIENT, type ProjectApiClient } from "./projectApiClient"; // A canvas as the PostHog canvases API returns it. interface ApiCanvas { @@ -51,7 +23,6 @@ interface ApiCanvas { context: string; generation_task_id: string | null; pinned_at: string | null; - is_home: boolean; current_version_id: string | null; published_build_id: string | null; created_by?: { @@ -101,7 +72,6 @@ function toRecord(api: ApiCanvas): DashboardRecord { createdAt: toEpoch(api.created_at) ?? 0, updatedAt: toEpoch(api.updated_at) ?? 0, pinnedAt: toEpoch(api.pinned_at), - isHome: api.is_home, currentVersionId: api.current_version_id, publishedBuildId: api.published_build_id, }; @@ -166,7 +136,6 @@ export class DashboardsService { channelId: string; name: string; templateId?: string; - isHome?: boolean; }): Promise { const api = await this.api.json(`canvases/`, "create canvas", { method: "POST", @@ -175,7 +144,6 @@ export class DashboardsService { channel_id: input.channelId, name: input.name, template_id: input.templateId ?? FREEFORM_TEMPLATE_ID, - is_home: input.isHome ?? false, }), }); return toRecord(api); @@ -321,104 +289,6 @@ export class DashboardsService { return toBuildRecord(build); } - // Ensure the channel has a home canvas: the freeform board shown when the - // channel opens. Idempotent — reuses the channel's existing home canvas, and - // seeds its source (via a real publish, so it gets built) when empty. - async ensureHomeCanvas(channelId: string): Promise { - let record = await this.findHomeCanvas(channelId); - if (!record) { - try { - record = await this.create({ - channelId, - name: HOME_CANVAS_NAME, - templateId: FREEFORM_TEMPLATE_ID, - isHome: true, - }); - } catch (error) { - // Only the is_home uniqueness race (409) means another client created - // it; reuse theirs. Any other failure (auth, capacity, network) must - // surface, not be masked as a race. - if (apiErrorStatus(error) !== 409) throw error; - record = await this.findHomeCanvas(channelId); - if (!record) throw new Error("Failed to create home canvas"); - } - } - if (!record.currentVersionId) { - record = await this.publishHomeSeed(record, channelId); - } - return record; - } - - // Rebuild a channel's home canvas from the default template. Non-destructive: - // the pre-reset source stays in the version history, so a revert restores it. - async resetHomeCanvas(channelId: string): Promise { - const record = await this.findHomeCanvas(channelId); - if (!record) return this.ensureHomeCanvas(channelId); - return this.publishHomeSeed(record, channelId); - } - - private async findHomeCanvas( - channelId: string, - ): Promise { - const rows = await this.api.listPaginated( - `canvases/?channel=${encodeURIComponent(channelId)}&is_home=true`, - "find home canvas", - { limit: 200 }, - ); - return rows.length ? toRecord(rows[0]) : null; - } - - // Publish the generated home board as the canvas's new head version. The - // board queries system.canvases/system.tasks ad hoc, so inline queries are - // declared as a capability. - private async publishHomeSeed( - record: DashboardRecord, - channelId: string, - ): Promise { - const project = { - schemaVersion: CANVAS_SOURCE_SCHEMA_VERSION, - files: { - [CANVAS_ENTRY_HTML]: SINGLE_FILE_INDEX_HTML, - [CANVAS_COMPONENT_PATH]: buildHomeCanvasCode(channelId, record.id), - }, - entryHtml: CANVAS_ENTRY_HTML, - dependencies: { - react: CANVAS_PLATFORM_MANIFEST.dependencies.react.version, - }, - canvasSdkVersion: CANVAS_PLATFORM_MANIFEST.canvasSdkVersion, - capabilities: { - posthog: { insights: [], inlineQueries: true, captureEvents: [] }, - network: { origins: [] }, - }, - }; - const publish = (expectedVersionId: string | null) => - this.api.json( - `canvases/${encodeURIComponent(record.id)}/publish/`, - "seed home canvas", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - project, - prompt: "Default home board", - expected_current_version_id: expectedVersionId, - }), - }, - ); - try { - await publish(record.currentVersionId ?? null); - } catch (error) { - // A concurrent seed can win the guarded publish between our read and - // POST. On the 409 version conflict, re-read the head and retry once - // against the fresh version id rather than failing the channel open. - if (apiErrorStatus(error) !== 409) throw error; - const conflicted = await this.get(record.id); - await publish(conflicted?.currentVersionId ?? null); - } - const fresh = await this.get(record.id); - return fresh ?? record; - } - async delete(id: string): Promise { const res = await this.api.fetch(`canvases/${encodeURIComponent(id)}/`, { method: "DELETE", @@ -429,395 +299,3 @@ export class DashboardsService { } } } - -// The seeded React source for a channel's home canvas. It runs in the freeform -// sandbox (null-origin iframe), so its only data avenue is `window.ph.query` -// (HogQL). It reads its lists from the `system.canvases`/`system.tasks` HogQL tables: -// - Canvases: this channel's canvases (excluding the home canvas). -// - Inbox / to-dos: stubbed (no data source yet) with an assignee filter. -// - Tasks: this channel's tasks, newest first. -// Each list shows a page at a time and loads more as its own box is scrolled. -// Rows and the "New" buttons drive host routing via the allowlisted -// `ph.navigate` bridge (toTask/toNewTask/toCanvas/toNewCanvas); the Inbox stub -// stays a no-op until it has a data source. channelId is host-supplied, so the -// canvas can only navigate within its own channel; homeCanvasId lets the -// Canvases list exclude this board. -function buildHomeCanvasCode(channelId: string, homeCanvasId: string): string { - const cid = JSON.stringify(channelId); - const hid = JSON.stringify(homeCanvasId); - return `import { useCallback, useEffect, useRef, useState } from "react"; - -const CHANNEL_ID = ${cid}; -const HOME_CANVAS_ID = ${hid}; -const PAGE_SIZE = 10; - -const ph = (window as any).ph; - -// Single-quote a value for inlining into a HogQL string literal. -function sql(v: string): string { - return "'" + String(v).replace(/'/g, "''") + "'"; -} - -type Row = { id: string; title: string; createdAt: string }; - -// Paginated reader for the channel's canvases or tasks, newest first. -function useChannelRows(kind: "dashboard" | "task") { - const [rows, setRows] = useState([]); - const [loading, setLoading] = useState(false); - const [done, setDone] = useState(false); - const offsetRef = useRef(0); - const busyRef = useRef(false); - - const loadMore = useCallback(async () => { - if (busyRef.current || done) return; - busyRef.current = true; - setLoading(true); - try { - const page = " ORDER BY created_at DESC LIMIT " + PAGE_SIZE + " OFFSET " + offsetRef.current; - const query = - kind === "dashboard" - ? "SELECT id, name, created_at FROM system.canvases" + - " WHERE channel_id = " + sql(CHANNEL_ID) + - " AND id != " + sql(HOME_CANVAS_ID) + page - : "SELECT id, title, created_at FROM system.tasks" + - " WHERE channel_id = " + sql(CHANNEL_ID) + page; - const res = await ph.query(query); - const batch: Row[] = ((res && res.results) || []).map((r: any[]) => ({ - id: String(r[0]), - title: String(r[1]), - createdAt: String(r[2]), - })); - offsetRef.current += batch.length; - setRows((prev) => prev.concat(batch)); - if (batch.length < PAGE_SIZE) setDone(true); - } catch (err) { - // Stop paging on error (e.g. the system table isn't available yet) rather - // than spinning; the section just shows what it has. - setDone(true); - } finally { - busyRef.current = false; - setLoading(false); - } - }, [kind, done]); - - useEffect(() => { - void loadMore(); - // Load the first page once on mount. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return { rows, loadMore, loading, done }; -} - -// A fixed-height, scrollable section card. A sentinel at the bottom (observed -// against THIS box, not the page) fires onLoadMore as the user scrolls near the -// end. Styled to match the PostHog app: greenish-gray neutrals, soft -// shadow, ~16px radius, a per-section accent dot. -function Section(props: { - title: string; - accent: string; - onNew: () => void; - loading: boolean; - done: boolean; - onLoadMore: () => void; - children: any; - // A "+ New" that isn't wired yet: disable it and explain via tooltip rather - // than offering a button that silently does nothing. - newDisabled?: boolean; - newTooltip?: string; -}) { - const scrollRef = useRef(null); - const sentinelRef = useRef(null); - - useEffect(() => { - const root = scrollRef.current; - const target = sentinelRef.current; - if (!root || !target) return; - const io = new IntersectionObserver( - (entries) => { - if (entries.some((e) => e.isIntersecting)) props.onLoadMore(); - }, - { root, rootMargin: "120px" }, - ); - io.observe(target); - return () => io.disconnect(); - }, [props.onLoadMore]); - - return ( -
-
-
- -

- {props.title} -

-
- -
-
- {props.children} - {!props.done ? ( -
- ) : null} - {props.loading ? ( -
Loading…
- ) : null} -
-
- ); -} - -function ListRow(props: { title: string; meta?: string; onClick?: () => void }) { - return ( -
{ - if (props.onClick && (e.key === "Enter" || e.key === " ")) { - e.preventDefault(); - props.onClick(); - } - }} - style={{ - padding: "8px 10px", - borderRadius: 8, - fontSize: 13, - color: "var(--row-color)", - display: "flex", - justifyContent: "space-between", - gap: 8, - cursor: props.onClick ? "pointer" : "default", - }} - > - - {props.title} - - {props.meta ? ( - {props.meta} - ) : null} -
- ); -} - -function Empty(props: { label: string }) { - return ( -
- {props.label} -
- ); -} - -function CanvasesSection() { - const { rows, loadMore, loading, done } = useChannelRows("dashboard"); - return ( -
ph.navigate?.toNewCanvas()} - loading={loading} - done={done} - onLoadMore={loadMore} - > - {rows.length === 0 && done ? : null} - {rows.map((r) => ( - ph.navigate?.toCanvas(r.id)} /> - ))} -
- ); -} - -function TasksSection() { - const { rows, loadMore, loading, done } = useChannelRows("task"); - return ( -
ph.navigate?.toNewTask()} - loading={loading} - done={done} - onLoadMore={loadMore} - > - {rows.length === 0 && done ? : null} - {rows.map((r) => ( - ph.navigate?.toTask(r.id)} - /> - ))} -
- ); -} - -// Inbox / to-dos: there's no data source for these yet, so this is a stub. The -// assignee toggle and "New" button are placeholders the host will wire up later. -function InboxSection() { - const [scope, setScope] = useState<"me" | "team">("me"); - const accent = "#1d4aff"; - return ( -
{}} loading={false} done={true} onLoadMore={() => {}} newDisabled={true} newTooltip="Coming soon"> -
- {(["me", "team"] as const).map((s) => { - const active = scope === s; - return ( - - ); - })} -
- -
- ); -} - -// Colors are CSS variables so the canvas follows the user's PostHog theme. The -// iframe loader toggles a \`dark\` class on (sandboxRuntime.applyTheme); -// \`html.dark\` overrides win on specificity, so every value flips with no JS. -const STYLE_TEXT = - ":root{" + - "--bg-from:#f4f5f0;--bg-to:#eceee8;--card-bg:#ffffff;--card-border:#e4e5de;" + - "--header-border:#eceee8;--title:#0d0d0d;--btn-border:#d8dbd1;--btn-bg:#f2f3ee;" + - "--btn-color:#3a4036;--btn-hover-bg:#eceee8;--btn-hover-border:#cbd0c3;" + - "--row-color:#3a4036;--row-hover-bg:#f2f3ee;--meta:#93998a;--empty:#a9af9f;" + - "--page-color:#3a4036;--scroll-thumb:#cbd0c3;--scroll-thumb-hover:#a9af9f}" + - "html.dark{" + - "--bg-from:#1b1d1a;--bg-to:#141613;--card-bg:#202220;--card-border:#33362e;" + - "--header-border:#2b2e27;--title:#f3f4ef;--btn-border:#3a3e34;--btn-bg:#2a2d26;" + - "--btn-color:#d4d7cd;--btn-hover-bg:#34372f;--btn-hover-border:#474c3f;" + - "--row-color:#d4d7cd;--row-hover-bg:#2a2d26;--meta:#8a917e;--empty:#6f7567;" + - "--page-color:#d4d7cd;--scroll-thumb:#3a3e34;--scroll-thumb-hover:#4a4f42}" + - ".ph-btn{transition:background .15s ease,border-color .15s ease,color .15s ease}" + - ".ph-btn:hover{background:var(--btn-hover-bg);border-color:var(--btn-hover-border)}" + - ".ph-row{transition:background .12s ease}" + - ".ph-row:hover{background:var(--row-hover-bg)}" + - "*::-webkit-scrollbar{width:10px;height:10px}" + - "*::-webkit-scrollbar-thumb{background:var(--scroll-thumb);border-radius:8px;border:2px solid transparent;background-clip:padding-box}" + - "*::-webkit-scrollbar-thumb:hover{background:var(--scroll-thumb-hover);background-clip:padding-box}"; - -export default function ChannelHome() { - return ( -
- -
- - - -
-
- ); -} -`; -} diff --git a/products/desktop/packages/core/src/canvas/services.ts b/products/desktop/packages/core/src/canvas/services.ts index 2f77302f6622..640344b6253e 100644 --- a/products/desktop/packages/core/src/canvas/services.ts +++ b/products/desktop/packages/core/src/canvas/services.ts @@ -61,11 +61,6 @@ export interface IDashboardsService { getBuilds(id: string): Promise; actOnBuild(input: CanvasBuildActionInput): Promise; rename(input: { id: string; name: string }): Promise; - // Idempotently create + seed a channel's home canvas, returning it. - ensureHomeCanvas(channelId: string): Promise; - // Publish a fresh template version to the home canvas (non-destructive; the - // prior version stays in history so the edit can be restored via revert). - resetHomeCanvas(channelId: string): Promise; delete(id: string): Promise; } diff --git a/products/desktop/packages/host-router/src/routers/dashboards.router.ts b/products/desktop/packages/host-router/src/routers/dashboards.router.ts index 6821c98d6efd..7603b889ea0d 100644 --- a/products/desktop/packages/host-router/src/routers/dashboards.router.ts +++ b/products/desktop/packages/host-router/src/routers/dashboards.router.ts @@ -10,7 +10,6 @@ import { createDashboardInput, dashboardIdInput, dashboardRecordSchema, - ensureHomeCanvasInput, listDashboardsInput, renameDashboardInput, revertCanvasInput, @@ -114,22 +113,6 @@ export const dashboardsRouter = router({ .mutation(({ ctx, input }) => ctx.container.get(DASHBOARDS_SERVICE).rename(input), ), - ensureHomeCanvas: publicProcedure - .input(ensureHomeCanvasInput) - .output(dashboardRecordSchema) - .mutation(({ ctx, input }) => - ctx.container - .get(DASHBOARDS_SERVICE) - .ensureHomeCanvas(input.channelId), - ), - resetHomeCanvas: publicProcedure - .input(ensureHomeCanvasInput) - .output(dashboardRecordSchema) - .mutation(({ ctx, input }) => - ctx.container - .get(DASHBOARDS_SERVICE) - .resetHomeCanvas(input.channelId), - ), delete: publicProcedure .input(dashboardIdInput) .mutation(({ ctx, input }) => diff --git a/products/desktop/packages/ui/src/features/canvas/freeform/FreeformCanvasView.tsx b/products/desktop/packages/ui/src/features/canvas/freeform/FreeformCanvasView.tsx index b46bc101d254..427638f9f1b2 100644 --- a/products/desktop/packages/ui/src/features/canvas/freeform/FreeformCanvasView.tsx +++ b/products/desktop/packages/ui/src/features/canvas/freeform/FreeformCanvasView.tsx @@ -1,5 +1,4 @@ import { - ArrowCounterClockwiseIcon, ArrowUUpLeftIcon, ArrowUUpRightIcon, ClockCounterClockwiseIcon, @@ -72,7 +71,7 @@ import { shouldClearCanvasBrowse, } from "./canvasVersionNavigation"; import { handleFreeformDataRequest } from "./freeformDataBridge"; -import { useCanvasNavigation, useHomeCanvasReset } from "./useHomeCanvasView"; +import { useCanvasNavigation } from "./useCanvasNavigation"; import { usePinnedArtifact } from "./usePinnedArtifact"; // A freeform (React-in-iframe) canvas. The rendered output is, in priority @@ -145,13 +144,6 @@ export function FreeformCanvasView({ [channels, channelId], ); - // The "Reset to default" affordance, shown only on a channel's home canvas. - const { - isHomeCanvas, - isResetting, - reset: onResetToDefault, - } = useHomeCanvasReset({ channelId, dashboardId, threadId }); - // Run status derivation (cloud vs local) lives in a pure, tested helper; a // terminal run record always ends "running" so a stale session can't strand // the canvas on "Generating". @@ -487,18 +479,6 @@ export function FreeformCanvasView({ {isReverting ? "Reverting…" : "Revert to this version"} )} - {isHomeCanvas && ( - - )} )} diff --git a/products/desktop/packages/ui/src/features/canvas/freeform/useCanvasNavigation.ts b/products/desktop/packages/ui/src/features/canvas/freeform/useCanvasNavigation.ts new file mode 100644 index 000000000000..ed45b5977842 --- /dev/null +++ b/products/desktop/packages/ui/src/features/canvas/freeform/useCanvasNavigation.ts @@ -0,0 +1,39 @@ +import type { CanvasNavIntent } from "@posthog/core/canvas/freeformSchemas"; +import { useCreateAndOpenDashboard } from "@posthog/ui/features/canvas/hooks/useDashboards"; +import { + navigateToChannelDashboard, + navigateToChannelTask, +} from "@posthog/ui/router/navigationBridge"; +import { openTaskInput } from "@posthog/ui/router/useOpenTask"; +import { useCallback } from "react"; + +/** + * Routes a canvas's allowlisted nav intent to real host navigation. channelId is + * host-supplied (never from the iframe), so the canvas can only move within its + * own channel. The returned callback switches exhaustively over the intent union. + */ +export function useCanvasNavigation( + channelId: string, +): (intent: CanvasNavIntent) => void { + const createAndOpen = useCreateAndOpenDashboard(channelId); + return useCallback( + (intent: CanvasNavIntent) => { + switch (intent.target) { + case "task": + navigateToChannelTask(channelId, intent.taskId); + break; + case "new-task": + // Via openTaskInput so a stale prefill can't leak into the composer. + openTaskInput({ channelId }); + break; + case "canvas": + navigateToChannelDashboard(channelId, intent.dashboardId); + break; + case "new-canvas": + void createAndOpen(); + break; + } + }, + [channelId, createAndOpen], + ); +} diff --git a/products/desktop/packages/ui/src/features/canvas/freeform/useHomeCanvasView.ts b/products/desktop/packages/ui/src/features/canvas/freeform/useHomeCanvasView.ts deleted file mode 100644 index 7404b28b034c..000000000000 --- a/products/desktop/packages/ui/src/features/canvas/freeform/useHomeCanvasView.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { CanvasNavIntent } from "@posthog/core/canvas/freeformSchemas"; -import { useHostTRPC } from "@posthog/host-router/react"; -import { invalidateCanvasLifecycle } from "@posthog/ui/features/canvas/hooks/invalidateCanvasLifecycle"; -import { - useCreateAndOpenDashboard, - useDashboard, -} from "@posthog/ui/features/canvas/hooks/useDashboards"; -import { useFreeformChatStore } from "@posthog/ui/features/canvas/stores/freeformChatStore"; -import { toast } from "@posthog/ui/primitives/toast"; -import { - navigateToChannelDashboard, - navigateToChannelTask, -} from "@posthog/ui/router/navigationBridge"; -import { openTaskInput } from "@posthog/ui/router/useOpenTask"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useState } from "react"; - -/** - * Routes a canvas's allowlisted nav intent to real host navigation. channelId is - * host-supplied (never from the iframe), so the canvas can only move within its - * own channel. The returned callback switches exhaustively over the intent union. - */ -export function useCanvasNavigation( - channelId: string, -): (intent: CanvasNavIntent) => void { - const createAndOpen = useCreateAndOpenDashboard(channelId); - return useCallback( - (intent: CanvasNavIntent) => { - switch (intent.target) { - case "task": - navigateToChannelTask(channelId, intent.taskId); - break; - case "new-task": - // Via openTaskInput so a stale prefill can't leak into the composer. - openTaskInput({ channelId }); - break; - case "canvas": - navigateToChannelDashboard(channelId, intent.dashboardId); - break; - case "new-canvas": - void createAndOpen(); - break; - } - }, - [channelId, createAndOpen], - ); -} - -/** - * The home-canvas "Reset to default" affordance. Only a channel's home canvas - * has a default template to reset to, so `isHomeCanvas` (from the canvas - * record's own flag) gates the button. `reset` regenerates the source - * server-side — the host publishes the template as a new head version and - * queues its rebuild — so afterwards it drops any version browse and refetches - * the record, version history, source, and build lifecycle. The prior version - * stays in history, so undo can still browse (and revert to) it. - */ -export function useHomeCanvasReset(args: { - channelId: string; - dashboardId: string; - threadId: string; -}): { - isHomeCanvas: boolean; - isResetting: boolean; - reset: () => Promise; -} { - const { channelId, dashboardId, threadId } = args; - const trpc = useHostTRPC(); - const queryClient = useQueryClient(); - const { dashboard } = useDashboard(dashboardId); - const setBrowseVersion = useFreeformChatStore((s) => s.setBrowseVersion); - const resetMutation = useMutation( - trpc.dashboards.resetHomeCanvas.mutationOptions(), - ); - const [isResetting, setIsResetting] = useState(false); - - const isHomeCanvas = dashboard?.isHome ?? false; - - const reset = useCallback(async () => { - setIsResetting(true); - try { - await resetMutation.mutateAsync({ channelId }); - setBrowseVersion(threadId, null); - await invalidateCanvasLifecycle(queryClient, trpc, dashboardId); - toast.success("Canvas reset to default", { - description: "Undo to browse your previous version.", - }); - } catch (error) { - toast.error("Couldn't reset canvas", { - description: error instanceof Error ? error.message : String(error), - }); - } finally { - setIsResetting(false); - } - }, [ - channelId, - dashboardId, - threadId, - resetMutation, - setBrowseVersion, - queryClient, - trpc, - ]); - - return { isHomeCanvas, isResetting, reset }; -} diff --git a/products/desktop/packages/ui/src/features/canvas/hooks/useDashboards.ts b/products/desktop/packages/ui/src/features/canvas/hooks/useDashboards.ts index beb642f05d10..2cf3fb0b0b40 100644 --- a/products/desktop/packages/ui/src/features/canvas/hooks/useDashboards.ts +++ b/products/desktop/packages/ui/src/features/canvas/hooks/useDashboards.ts @@ -170,11 +170,6 @@ export function useDashboardMutations() { const setPinned = useMutation( trpc.dashboards.setPinned.mutationOptions({ onSuccess: invalidate }), ); - const ensureHome = useMutation( - trpc.dashboards.ensureHomeCanvas.mutationOptions({ - onSuccess: invalidate, - }), - ); return { // Refresh the canvas queries after a mutation that didn't go through this @@ -205,10 +200,6 @@ export function useDashboardMutations() { // shows in the channel's Pinned menu for every member. setPinned: (id: string, pinned: boolean) => setPinned.mutateAsync({ id, pinned }), - // Ensure a channel has its home canvas (creating + seeding it if absent). - // Idempotent server-side; returns the home canvas record. - ensureHomeCanvas: (channelId: string) => - ensureHome.mutateAsync({ channelId }), isCreating: create.isPending, isDeleting: remove.isPending, isSavingContext: saveContext.isPending, @@ -216,48 +207,6 @@ export function useDashboardMutations() { }; } -/** - * Open a channel's home canvas in the main content pane. The home canvas is - * resolved (and created on first open) by the dashboards service, which is - * idempotent server-side. - */ -export function useOpenHomeCanvas(): (channel: { - id: string; -}) => Promise { - const navigate = useNavigate(); - const trpc = useHostTRPC(); - const queryClient = useQueryClient(); - const { ensureHomeCanvas } = useDashboardMutations(); - - return useCallback( - async (channel) => { - try { - // The channel's dashboards list is usually already cached; a seeded - // home canvas found there can be opened without a server round trip. - // Only when none exists (or it's unseeded) does the idempotent - // ensureHomeCanvas create/seed it. - const cachedHome = queryClient - .getQueryData( - trpc.dashboards.list.queryKey({ channelId: channel.id }), - ) - ?.find((d) => d.isHome && d.currentVersionId); - const dashboardId = - cachedHome?.id ?? (await ensureHomeCanvas(channel.id)).id; - await navigate({ - to: "/website/$channelId/dashboards/$dashboardId", - params: { channelId: channel.id, dashboardId }, - }); - } catch (error) { - log.error("Failed to open home canvas", { error }); - toast.error("Couldn't open channel home", { - description: error instanceof Error ? error.message : String(error), - }); - } - }, - [navigate, ensureHomeCanvas, queryClient, trpc], - ); -} - /** * Create an empty canvas in a channel, enter edit mode, and navigate to it. * `opts.channelId` overrides the bound channel, for callers whose channel is diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index 5e6d0a1f1bc2..94853924ef4d 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -13289,7 +13289,6 @@ export namespace Schemas { readonly pinned: boolean; /** @nullable */ readonly pinned_at: string | null; - readonly is_home: boolean; /** * Id of the live source version — pass as expected_current_version_id on publish. Null before the first publish. * @nullable @@ -13494,8 +13493,6 @@ export namespace Schemas { * @maxLength 64 */ template_id?: string; - /** Create the canvas as the channel's home board (at most one per channel). */ - is_home?: boolean; } /** @@ -77660,10 +77657,6 @@ export namespace Schemas { * Only return canvases in this channel. */ channel?: string; - /** - * Filter by channel-home status. - */ - is_home?: boolean; /** * Number of results to return per page. */ diff --git a/services/mcp/src/generated/canvas/api.ts b/services/mcp/src/generated/canvas/api.ts index 69791f43d550..041cb68f9ee4 100644 --- a/services/mcp/src/generated/canvas/api.ts +++ b/services/mcp/src/generated/canvas/api.ts @@ -24,7 +24,6 @@ export const CanvasesListParams = /* @__PURE__ */ zod.object({ export const CanvasesListQueryParams = /* @__PURE__ */ zod.object({ channel: zod.string().optional().describe('Only return canvases in this channel.'), - is_home: zod.boolean().optional().describe('Filter by channel-home status.'), limit: zod.number().optional().describe('Number of results to return per page.'), offset: zod.number().optional().describe('The initial index from which to return the results.'), }) @@ -45,8 +44,6 @@ export const canvasesCreateBodyNameMax = 400 export const canvasesCreateBodyTemplateIdDefault = `freeform` export const canvasesCreateBodyTemplateIdMax = 64 -export const canvasesCreateBodyIsHomeDefault = false - export const CanvasesCreateBody = /* @__PURE__ */ zod .object({ name: zod.string().max(canvasesCreateBodyNameMax).describe('Display name for the canvas.'), @@ -56,10 +53,6 @@ export const CanvasesCreateBody = /* @__PURE__ */ zod .max(canvasesCreateBodyTemplateIdMax) .default(canvasesCreateBodyTemplateIdDefault) .describe('Canvas template identifier.'), - is_home: zod - .boolean() - .default(canvasesCreateBodyIsHomeDefault) - .describe("Create the canvas as the channel's home board (at most one per channel)."), }) .describe('Payload for creating a new, empty canvas in a channel.') diff --git a/services/mcp/src/tools/generated/canvas.ts b/services/mcp/src/tools/generated/canvas.ts index 38a6d8c6c160..b48dd0750788 100644 --- a/services/mcp/src/tools/generated/canvas.ts +++ b/services/mcp/src/tools/generated/canvas.ts @@ -55,9 +55,6 @@ const canvasCreate = (): ToolBase => if (params.template_id !== undefined) { body['template_id'] = params.template_id } - if (params.is_home !== undefined) { - body['is_home'] = params.is_home - } const result = await context.api.request({ method: 'POST', path: `/api/projects/${encodeURIComponent(String(projectId))}/canvases/`, @@ -112,7 +109,6 @@ const canvasList = (): ToolBase Date: Tue, 4 Aug 2026 11:17:24 +0100 Subject: [PATCH 3/4] fix(canvas): preserve legacy source as a version on first publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A migrated canvas carries its pre-remodel source in legacy_code, and the first publish nulled that column while creating the new version from the incoming project only — the old source never became a version, so undo and revert could not reach it and the only copy was discarded. Materialize legacy_code as a real parent version ("Imported source") before the first post-migration publish, with the same upload-then-commit posture as the main project. Generated-By: PostHog Code Task-Id: d75a71da-b662-4101-af83-0588664849c2 --- products/canvas/backend/build_service.py | 25 +++++++++++-- .../backend/tests/test_build_service.py | 35 ++++++++++++++++++- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/products/canvas/backend/build_service.py b/products/canvas/backend/build_service.py index 5378266a7751..fc2c4f117677 100644 --- a/products/canvas/backend/build_service.py +++ b/products/canvas/backend/build_service.py @@ -44,6 +44,7 @@ SYNTHETIC_INDEX_HTML, diagnostic, has_errors, + synthetic_source_project, validate_relative_path, validate_source_project, ) @@ -312,8 +313,6 @@ def current_source_project(canvas: Canvas) -> tuple[dict[str, Any], str | None]: if canvas.current_source_version_id: version = CanvasSourceVersion.objects.for_team(canvas.team_id).get(pk=canvas.current_source_version_id) return read_source_project(version), str(version.id) - from products.canvas.backend.source import synthetic_source_project # noqa: PLC0415 - return synthetic_source_project(canvas.legacy_code), None @@ -424,11 +423,33 @@ def publish_source_project( key, digest, size = upload_source_project(canvas.team_id, canvas.id, project) + # A migrated canvas's pre-relational source must survive its first publish: + # it becomes a real parent version here so history (undo/revert) can reach + # it — otherwise nulling legacy_code below would discard the only copy. + # Same upload-then-commit posture as the main project. + legacy_upload: tuple[str, str, int] | None = None + if current_id is None and (canvas.legacy_code or "").strip(): + legacy_upload = upload_source_project(canvas.team_id, canvas.id, synthetic_source_project(canvas.legacy_code)) + with transaction.atomic(), team_scope(canvas.team_id): canvas = _claim_canvas_head( canvas, has_expected_version=has_expected_version, expected_version_id=expected_version_id ) first_publish = canvas.current_source_version_id is None and not (canvas.legacy_code or "").strip() + if ( + legacy_upload is not None + and canvas.current_source_version_id is None + and (canvas.legacy_code or "").strip() + ): + legacy_key, legacy_digest, legacy_size = legacy_upload + canvas.current_source_version = CanvasSourceVersion.objects.create( + team_id=canvas.team_id, + canvas=canvas, + source_hash=legacy_digest, + source_object_key=legacy_key, + source_size=legacy_size, + prompt="Imported source", + ) version = CanvasSourceVersion.objects.create( team_id=canvas.team_id, canvas=canvas, diff --git a/products/canvas/backend/tests/test_build_service.py b/products/canvas/backend/tests/test_build_service.py index 9136127d0927..edc8ca08f74f 100644 --- a/products/canvas/backend/tests/test_build_service.py +++ b/products/canvas/backend/tests/test_build_service.py @@ -10,7 +10,7 @@ from posthog.models.scoping import team_scope from products.canvas.backend import build_service -from products.canvas.backend.models import Canvas, CanvasBuild +from products.canvas.backend.models import Canvas, CanvasBuild, CanvasSourceVersion from products.canvas.backend.source import synthetic_source_project from products.canvas.backend.tests.test_canvas_api import InMemoryStorage from products.tasks.backend.models import Channel @@ -301,3 +301,36 @@ def test_cleanup_prunes_aged_artifacts_but_keeps_published_and_pinned(self): assert str(aged[2].id) in kept # newest other ready build (instant rollback) assert str(published.id) not in kept # aged past retention, unprotected assert pruned == 1 + + +class TestLegacySourcePreservation(BuildServiceBaseTest): + def test_first_publish_materializes_legacy_code_as_parent_version(self): + legacy = "export default function Legacy() { return null }" + Canvas.objects.unscoped().filter(id=self.canvas.id).update(legacy_code=legacy) + self.canvas.refresh_from_db() + + canvas, version, _build, first_publish = build_service.publish_source_project( + self.canvas, + project=synthetic_source_project("export default function Rewrite() { return null }"), + prompt="rewrite", + name=None, + has_expected_version=True, + expected_version_id=None, + task_id=None, + created_by_id=None, + ) + + head = CanvasSourceVersion.objects.unscoped().get(pk=version.id) + assert head.parent_version_id is not None + legacy_version = CanvasSourceVersion.objects.unscoped().get(pk=head.parent_version_id) + assert build_service.read_source_project(legacy_version) == synthetic_source_project(legacy) + assert canvas.current_source_version_id == head.id + assert canvas.legacy_code is None + assert not first_publish + + def test_publish_on_fresh_canvas_creates_single_root_version(self): + self._publish() + + versions = CanvasSourceVersion.objects.unscoped().filter(canvas_id=self.canvas.id) + assert versions.count() == 1 + assert versions.get().parent_version_id is None From e675e6ecc297f00ff3f8b9e2a7e26f789a0a2573 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Tue, 4 Aug 2026 11:17:42 +0100 Subject: [PATCH 4/4] chore(canvas): require visible error states in generated canvases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated boards swallowed query failures into empty states, so real breakage (a missing table, an auth failure) rendered as "no data" — how the unregistered system.canvases table read as data loss. The authoring skills now require a rejected query to render a visible error with a retry, distinct from a successful-but-empty result, and the starter scaffold demonstrates the pattern. Generated-By: PostHog Code Task-Id: d75a71da-b662-4101-af83-0588664849c2 --- .../building-react-quill-canvases/SKILL.md | 11 ++++++-- .../references/starter-scaffold.md | 26 ++++++++++++++++--- .../skills/querying-canvas-data/SKILL.md | 8 ++++-- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/products/canvas/skills/building-react-quill-canvases/SKILL.md b/products/canvas/skills/building-react-quill-canvases/SKILL.md index 361ee58b0708..1a266cd23677 100644 --- a/products/canvas/skills/building-react-quill-canvases/SKILL.md +++ b/products/canvas/skills/building-react-quill-canvases/SKILL.md @@ -61,12 +61,19 @@ text-card-foreground`; borders `border-border`. Never a hardcoded hex or light-o - Write Unicode glyphs (curly quotes, ellipsis, arrows, emoji) as literal characters in JSX — `\uXXXX` escapes render verbatim in JSX text. -## Loading states +## Loading, error, and empty states Every data point renders a skeleton in its own `Card` while loading or refreshing: `SkeletonText` (matching `lines` and text-size `className`) for text/number values, `Skeleton` for blocks/charts. Drive `isLoading` off the data calls and set it true again on refresh; never show a blank or a -jumping layout, and handle the empty/error case. +jumping layout. + +A failed query and an empty result are different states — never let one render as the other. +`.catch` on every `ph.query`/`ph.loadInsight` must set an error state that renders visibly (the +message plus a Retry button wired to the refresh nonce, as in the starter scaffold), not fall +through to zeros, an empty chart, or a "no data yet" message. A query that silently swallows its +error makes real breakage (a missing table, an auth failure, a bad query) look like missing data. +Reserve the empty state for a query that succeeded with no rows. ## Date window diff --git a/products/canvas/skills/building-react-quill-canvases/references/starter-scaffold.md b/products/canvas/skills/building-react-quill-canvases/references/starter-scaffold.md index 35be5b5bb9af..b0e15c9abf89 100644 --- a/products/canvas/skills/building-react-quill-canvases/references/starter-scaffold.md +++ b/products/canvas/skills/building-react-quill-canvases/references/starter-scaffold.md @@ -36,6 +36,7 @@ export default function Canvas() { const [open, setOpen] = useState(false) const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) const [total, setTotal] = useState(0) const [series, setSeries] = useState([]) // Refresh plumbing: bump this nonce to re-run the data effect on demand. @@ -44,6 +45,7 @@ export default function Canvas() { useEffect(() => { let cancelled = false setLoading(true) + setError(null) // Typed query node, computed by PostHog's own runner so the numbers match // the UI exactly. `event: null` = all events (works on any project). ph.query({ @@ -62,9 +64,12 @@ export default function Canvas() { setSeries((s.days ?? []).map((day, i) => ({ day, value: s.data?.[i] ?? 0 }))) setLoading(false) }) - .catch((error) => { - if (!cancelled) setLoading(false) - throw error + .catch((err) => { + if (cancelled) return + setLoading(false) + // A failed query must LOOK failed — falling through to zeros or an + // empty chart reads as "no data" and hides real breakage. + setError(String(err?.message ?? err)) }) return () => { cancelled = true @@ -101,6 +106,17 @@ export default function Canvas() { + {error && ( + + +

Couldn't load data: {error}

+ +
+
+ )} +
@@ -109,6 +125,8 @@ export default function Canvas() { {loading ? ( + ) : error ? ( +

) : ( {total.toLocaleString()} )} @@ -123,6 +141,8 @@ export default function Canvas() { {loading ? ( + ) : error ? ( +

) : (
diff --git a/products/canvas/skills/querying-canvas-data/SKILL.md b/products/canvas/skills/querying-canvas-data/SKILL.md index 3c6f68f8a3f7..386c69baf30c 100644 --- a/products/canvas/skills/querying-canvas-data/SKILL.md +++ b/products/canvas/skills/querying-canvas-data/SKILL.md @@ -48,8 +48,12 @@ calls at runtime, and validation fails on undeclared literals. - **SQL results**: `{ columns: string[], results: rows[][] }` — each row an array of cell values in `columns` order. -Load data in `useEffect` with `useState`, show a loading state, and handle empty/error. Aggregate -in the query; never fetch raw event dumps. +Load data in `useEffect` with `useState`, show a loading state, and aggregate in the query; never +fetch raw event dumps. Treat a rejected query and an empty result as different states: `.catch` +must set an error state that renders visibly (message + retry), never fall through to zeros, an +empty chart, or a "no data" message — a swallowed error makes real breakage (a missing table, an +auth failure) look like missing data. Reserve the empty state for a query that succeeded with no +rows. ## Date windows