From 0f1081d423cecd63ab5ba9f9b466a3c87eb256b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Thu, 21 May 2026 23:35:41 +0100 Subject: [PATCH] feat(share): add public share links via GitHub Gists (#995) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(share): add public share links via GitHub Gists Adds a Share button to the chat panel header that publishes the current session as a secret GitHub Gist on the user's account. The share link routes through gistpreview.github.io to render share.html as a styled kandev-themed page; snapshot.json + README.md ride along for machine-readable + fallback views. Snapshots run a redaction pass (absolute paths, common credential shapes, .env contents, shell env vars) and strip prompt blocks via the shared sysprompt helper. Message filtering whitelists user prose + tool_* types so internal noise never leaks into shares. Spec at docs/specs/public-share-links/spec.md. * fix(share): coerce pendingClarification to bool for ClarificationSection A late refactor extracted ClarificationSection with `pendingClarification: boolean` on its props, but the call site passes the actual Message|null value. CI's tsc build catches the mismatch; runtime was unaffected because the section only uses the value in a truthiness check. Coerce with Boolean() at the call site to keep the prop's intent narrow. * fix(share): address PR #995 review feedback Bugs (flagged by Greptile, Cubic, Claude): - abs-path redaction split on `root + "/"` so sibling dirs like /home/user/proj2 don't get corrupted when workspaceRoot is /home/user/proj; regression test added. - GHClient.DeleteGist now wraps "HTTP 404" stderr as *GitHubAPIError so share.IsAlreadyGone matches consistently with the PAT client; this restores the "gist already revoked upstream" soft-success path for CLI-authenticated users. - SnapshotSizeBytes is now measured from the same MarshalIndent payload the gist backend uploads, so the size shown in the UI matches the actual gist bytes (was ~20-40% underreported). Polish (flagged by CodeRabbit, Cubic): - Tool name + tool output now HTML-escaped; tool call args and output render via
 instead of triple-backtick fences so payloads
   containing ``` can't break out of the code block.
 - share-list.tsx: clear `busy` via finally and break error layout out
   of the justify-between row.
 - use-shares.ts: convert UseSharesResult to interface (repo TS rules);
   reset error+isLoading when ids are missing.
 - use-shares.test.tsx: wait on the actual async outcome (shares
   populated, listShares called) instead of isLoading flipping false.
 - provider.go: replace `repo.Close` cleanup with an explicit noop
   closure so the comment ("Cleanup is a no-op") and the impl match.
 - spec.md: aligned the shareability rule across What/Failure/Scenarios/
   Out-of-scope — any non-prehistory state can be shared; added language
   tags to fenced code blocks for markdownlint.

Skipped intentionally (reply on each thread):
 - HTML-escape every metadata badge: the values are controlled enum
   strings from kandev's own schema, not user input. Defense-in-depth
   noise. Tool name (which can come from agent runtime) IS escaped.
 - useShares request-token guard against stale responses: the dialog
   owns the hook lifecycle and opens against a single (taskId,
   sessionId) — the race the comment describes can't occur here.

* fix(share): redact paths for every worktree, not just the first

A single TaskSession can carry multiple worktrees when the task spans
multiple repos. The redactor previously only saw the first worktree
path, so absolute paths from secondary worktrees leaked into public
shares instead of being rewritten to repo-relative form.

NewRedactor is now variadic and applies the abs-path rule against every
root it receives, sorted longest-first so a root that is a prefix of
another (e.g. /workspace vs /workspace/repo1) gets matched by the more
specific one first. workspaceRootFor in builder.go becomes
workspaceRootsFor and emits every WorktreePath plus the WorkspacePath
fallback.

Flagged by Cubic on PR #995.

* fix(share): address PR #995 review round 3

Bugs (flagged by Cubic + Claude on gh_client.go:714):
 - DeleteGist now calls the existing isNotFoundErr helper instead of an
   inline strings.Contains(err, "HTTP 404"). The helper also covers
   "404 Not Found" and "status: 404", which some gh versions emit, so
   the share service's "gist already revoked upstream" soft-success
   path now fires consistently across CLI auth users.

Bugs (flagged by Claude on builder.go:220):
 - toolCallBlock used to read metadata["tool_name"] and metadata["args"]
   but production never writes those keys; the typed payload lives under
   metadata["normalized"] (a *streams.NormalizedPayload) and the tool
   kind is on Message.Type. Switched to deriving ToolName from
   strings.TrimPrefix(m.Type, "tool_") (gives "read", "execute", "edit",
   …) and reading args from metadata["normalized"], with backward-compat
   fallback to the older "tool_name"/"args" keys for the debug-fixture
   replay path. New TestBuildSnapshot_ToolCall_ProductionMetadataShape
   exercises the real session shape.

Bugs (flagged by CodeRabbit on redaction.go:110):
 - Bare workspace-root occurrences (pwd output, end-of-sentence, quoted
   paths) used to leak because the matcher only handled root + "/".
   Switched to a precompiled regex per root —
   regexp.QuoteMeta(root) + `(?:/|\b)` — so both `root/...` and standalone
   `root` get stripped, while sibling dirs like /workspace2 still don't
   match. Test TestRedactor_AbsPath_StandaloneRootStripped covers the
   bare-root variants.

Doc (flagged by Claude on spec.md):
 - Two malformed fence closers (`go and `text on closing fences) corrected
   to plain ``` so the file is markdownlint-clean.

* fix(share): narrow abs-path boundary to avoid sibling-prefix corruption

Round 4 review (Claude + CodeRabbit) caught a regression in the regex
introduced by 436cb66: `(?:/|\b)` used `\b` (word boundary) to catch
bare-root cases, but `\b` fires on any word↔non-word transition. So a
sibling path like `/workspace.bak`, `/workspace-old`, or
`/workspace-other` would false-match the `/workspace` root (since
e→. and e→- are word boundaries), stripping it and leaving a
corrupted `.bak` / `-old/file.ts` in the share.

Replaced the matcher with `(/|\s|$)`: path separator, whitespace, or
end-of-string only — never punctuation or quote chars. The boundary
is captured and re-emitted via ReplaceAllStringFunc (or dropped, in
the `/` case) so prose loses only the root, not the surrounding space.

Added TestRedactor_AbsPath_SiblingsWithPunctuationNotCorrupted covering
`.bak`, `-old`, `_alt` siblings; the digit-sibling test still passes.
The quoted-bare-root case (`"/workspace"`) no longer matches and was
dropped from the standalone-root test — contrived enough that a small
leak there is preferable to corrupting realistic sibling paths.

* refactor(share): move share button into chat status bar

Replaces the dedicated PanelHeaderBarSplit above the message list with
a slot in ChatStatusBar (the existing row holding TodoIndicator and
PRStatusChip just above the chat input). The button anchors right via
ml-auto; when the "proceed to next step" affordance is present it
takes the right anchor instead and the share button stays inline.

Also tightens the iconOnly variant in ShareButton to a ghost style
that matches the muted-foreground chips in the status bar, and exports
shareableSessionStateClient from share-button.tsx so the gating logic
lives next to the entry point it controls.

Net -23 lines in task-chat-panel.tsx: the extra header bar and the
local gate helper are gone.

* docs(share): broaden tooltip + add share/ to CLAUDE.md tree

The share tooltip read "Share this completed task with a public link",
but shareableSessionStateClient allows RUNNING / IDLE / WAITING_FOR_INPUT
/ FAILED / CANCELLED too. A user hovering mid-run saw misleading copy;
reworded to "Share this task conversation as a public link" which
matches the actual gate.

Also added the missing internal/task/share/ entry to the task/ subtree
in CLAUDE.md so the new package shows up in the repo map — flagged
across multiple rounds.

* docs: add internal/task/share/ to repo map

Carryover from the share PR — AGENTS.md (which CLAUDE.md symlinks to)
was missing the share/ subpackage under task/. Adding it now so the
repo map matches what's checked in.

---------

Co-authored-by: Kandev Agent 
---
 AGENTS.md                                     |   3 +-
 apps/backend/cmd/kandev/helpers.go            |   4 +
 apps/backend/cmd/kandev/main.go               |   2 +-
 apps/backend/cmd/kandev/services.go           |  31 +-
 apps/backend/cmd/kandev/types.go              |   2 +
 apps/backend/internal/github/client.go        |   8 +
 .../internal/github/controller_test.go        |   4 +
 apps/backend/internal/github/gh_client.go     |  76 ++++
 apps/backend/internal/github/gists.go         |  22 +
 apps/backend/internal/github/mock_client.go   |  76 ++++
 .../internal/github/mock_client_test.go       |  49 +++
 apps/backend/internal/github/noop_client.go   |   8 +
 apps/backend/internal/github/pat_client.go    |  90 ++++
 apps/backend/internal/task/share/backend.go   |  44 ++
 apps/backend/internal/task/share/builder.go   | 279 +++++++++++++
 .../internal/task/share/builder_test.go       | 389 ++++++++++++++++++
 .../internal/task/share/gist_backend.go       | 161 ++++++++
 .../internal/task/share/gist_backend_test.go  | 128 ++++++
 apps/backend/internal/task/share/gist_css.go  | 386 +++++++++++++++++
 apps/backend/internal/task/share/gist_html.go | 241 +++++++++++
 .../internal/task/share/gist_html_test.go     | 220 ++++++++++
 .../internal/task/share/gist_html_text.go     | 148 +++++++
 .../internal/task/share/gist_markdown.go      | 258 ++++++++++++
 .../internal/task/share/gist_markdown_test.go |  90 ++++
 apps/backend/internal/task/share/http.go      | 227 ++++++++++
 apps/backend/internal/task/share/http_test.go | 298 ++++++++++++++
 apps/backend/internal/task/share/provider.go  |  43 ++
 apps/backend/internal/task/share/redaction.go | 212 ++++++++++
 .../internal/task/share/redaction_test.go     | 290 +++++++++++++
 .../backend/internal/task/share/repository.go | 183 ++++++++
 .../internal/task/share/repository_test.go    | 146 +++++++
 apps/backend/internal/task/share/service.go   | 130 ++++++
 .../internal/task/share/service_test.go       | 259 ++++++++++++
 apps/backend/internal/task/share/snapshot.go  |  81 ++++
 .../components/task/chat/chat-input-area.tsx  |  15 +-
 .../components/task/share/share-button.tsx    |  76 ++++
 .../task/share/share-dialog.test.tsx          |  99 +++++
 .../components/task/share/share-dialog.tsx    | 235 +++++++++++
 apps/web/components/task/share/share-list.tsx |  87 ++++
 .../task/share/share-snapshot-preview.tsx     | 137 ++++++
 apps/web/components/task/task-chat-panel.tsx  | 182 +++++---
 .../hooks/domains/session/use-shares.test.tsx |  72 ++++
 apps/web/hooks/domains/session/use-shares.ts  |  49 +++
 apps/web/lib/api/domains/share-api.test.ts    | 118 ++++++
 apps/web/lib/api/domains/share-api.ts         |  93 +++++
 docs/specs/INDEX.md                           |   1 +
 docs/specs/public-share-links/spec.md         | 361 ++++++++++++++++
 47 files changed, 6060 insertions(+), 53 deletions(-)
 create mode 100644 apps/backend/internal/github/gists.go
 create mode 100644 apps/backend/internal/task/share/backend.go
 create mode 100644 apps/backend/internal/task/share/builder.go
 create mode 100644 apps/backend/internal/task/share/builder_test.go
 create mode 100644 apps/backend/internal/task/share/gist_backend.go
 create mode 100644 apps/backend/internal/task/share/gist_backend_test.go
 create mode 100644 apps/backend/internal/task/share/gist_css.go
 create mode 100644 apps/backend/internal/task/share/gist_html.go
 create mode 100644 apps/backend/internal/task/share/gist_html_test.go
 create mode 100644 apps/backend/internal/task/share/gist_html_text.go
 create mode 100644 apps/backend/internal/task/share/gist_markdown.go
 create mode 100644 apps/backend/internal/task/share/gist_markdown_test.go
 create mode 100644 apps/backend/internal/task/share/http.go
 create mode 100644 apps/backend/internal/task/share/http_test.go
 create mode 100644 apps/backend/internal/task/share/provider.go
 create mode 100644 apps/backend/internal/task/share/redaction.go
 create mode 100644 apps/backend/internal/task/share/redaction_test.go
 create mode 100644 apps/backend/internal/task/share/repository.go
 create mode 100644 apps/backend/internal/task/share/repository_test.go
 create mode 100644 apps/backend/internal/task/share/service.go
 create mode 100644 apps/backend/internal/task/share/service_test.go
 create mode 100644 apps/backend/internal/task/share/snapshot.go
 create mode 100644 apps/web/components/task/share/share-button.tsx
 create mode 100644 apps/web/components/task/share/share-dialog.test.tsx
 create mode 100644 apps/web/components/task/share/share-dialog.tsx
 create mode 100644 apps/web/components/task/share/share-list.tsx
 create mode 100644 apps/web/components/task/share/share-snapshot-preview.tsx
 create mode 100644 apps/web/hooks/domains/session/use-shares.test.tsx
 create mode 100644 apps/web/hooks/domains/session/use-shares.ts
 create mode 100644 apps/web/lib/api/domains/share-api.test.ts
 create mode 100644 apps/web/lib/api/domains/share-api.ts
 create mode 100644 docs/specs/public-share-links/spec.md

diff --git a/AGENTS.md b/AGENTS.md
index 0270caee12..78a6712181 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -83,7 +83,8 @@ apps/backend/
 │   │   ├── handlers/     # Task event handlers
 │   │   ├── models/       # Task, Session, Executor, Message models
 │   │   ├── repository/   # Database access (SQLite)
-│   │   └── service/      # Task business logic
+│   │   ├── service/      # Task business logic
+│   │   └── share/        # Public share links: snapshot builder, gist backend, service, redaction
 │   ├── office/           # Autonomous agent management (Office feature)
 │   │   ├── agents/       # Agent instance CRUD + auth guards
 │   │   ├── approvals/    # Approval requests and decisions
diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/cmd/kandev/helpers.go
index 70de787359..e15f342762 100644
--- a/apps/backend/cmd/kandev/helpers.go
+++ b/apps/backend/cmd/kandev/helpers.go
@@ -643,6 +643,10 @@ func registerTaskRoutes(p routeParams, planService *taskservice.PlanService, han
 	taskhandlers.RegisterProcessRoutes(p.router, p.taskSvc, p.lifecycleMgr, p.log)
 	analyticshandlers.RegisterStatsRoutes(p.router, p.analyticsRepo, p.log)
 	agenthandlers.RegisterShellRoutes(p.router, p.lifecycleMgr, p.log)
+	if p.services.Share != nil {
+		p.services.Share.RegisterRoutes(p.router)
+		p.log.Debug("Registered Public Share Links handlers (HTTP)")
+	}
 	p.log.Debug("Registered Task Service handlers (HTTP + WebSocket)")
 }
 
diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go
index f39e50cbfd..880b4b0ce8 100644
--- a/apps/backend/cmd/kandev/main.go
+++ b/apps/backend/cmd/kandev/main.go
@@ -279,7 +279,7 @@ func startServices( //nolint:cyclop
 		return false
 	}
 
-	services, agentSettingsController, err := provideServices(cfg, log, repos, dbPool, eventBus, agentRegistry)
+	services, agentSettingsController, err := provideServices(cfg, log, repos, dbPool, eventBus, agentRegistry, Version)
 	if err != nil {
 		log.Error("Failed to initialize services", zap.Error(err))
 		return false
diff --git a/apps/backend/cmd/kandev/services.go b/apps/backend/cmd/kandev/services.go
index 47ccd924e1..23b4778c92 100644
--- a/apps/backend/cmd/kandev/services.go
+++ b/apps/backend/cmd/kandev/services.go
@@ -25,13 +25,14 @@ import (
 	"github.com/kandev/kandev/internal/slack"
 	taskmodels "github.com/kandev/kandev/internal/task/models"
 	taskservice "github.com/kandev/kandev/internal/task/service"
+	"github.com/kandev/kandev/internal/task/share"
 	userservice "github.com/kandev/kandev/internal/user/service"
 	utilityservice "github.com/kandev/kandev/internal/utility/service"
 	wfmodels "github.com/kandev/kandev/internal/workflow/models"
 	workflowservice "github.com/kandev/kandev/internal/workflow/service"
 )
 
-func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories, dbPool *db.Pool, eventBus bus.EventBus, agentRegistry *registry.Registry) (*Services, *agentsettingscontroller.Controller, error) {
+func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories, dbPool *db.Pool, eventBus bus.EventBus, agentRegistry *registry.Registry, version string) (*Services, *agentsettingscontroller.Controller, error) {
 	// Load custom TUI agents from DB into registry before discovery
 	loadCustomTUIAgents(context.Background(), repos, agentRegistry, log)
 
@@ -92,6 +93,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories
 	jiraSvc := initJiraService(dbPool, eventBus, repos.Secrets, log)
 	linearSvc := initLinearService(dbPool, eventBus, repos.Secrets, log)
 	slackSvc := initSlackService(dbPool, repos.Secrets, log)
+	shareHTTP := initShareHandlers(dbPool, repos.Task, githubSvc, log, version)
 
 	// Plumb GitHub branch listing into the task service so provider-backed
 	// ("Remote") repos serve branches from the GitHub API rather than relying
@@ -112,6 +114,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories
 		Jira:     jiraSvc,
 		Linear:   linearSvc,
 		Slack:    slackSvc,
+		Share:    shareHTTP,
 		// Office is constructed later in initOfficeServices once all
 		// of its dependencies (config loader, task integrations, etc.) are available.
 		Office: nil,
@@ -263,6 +266,32 @@ func initLinearService(dbPool *db.Pool, eventBus bus.EventBus, secretsStore secr
 	return svc
 }
 
+// initShareHandlers wires up the public-share-links HTTP surface. Failures
+// are non-fatal: the rest of the backend boots without the share endpoints.
+// The github.Client may be nil; CreateShare will fail at the IsAuthenticated
+// probe with a 412 in that case.
+func initShareHandlers(
+	dbPool *db.Pool,
+	taskRepo share.TaskReader,
+	githubSvc *github.Service,
+	log *logger.Logger,
+	version string,
+) *share.HTTPHandlers {
+	var ghClient github.Client
+	if githubSvc != nil {
+		ghClient = githubSvc.Client()
+	}
+	if ghClient == nil {
+		ghClient = &github.NoopClient{}
+	}
+	h, _, err := share.Provide(dbPool.Writer(), dbPool.Reader(), taskRepo, ghClient, log, share.Config{KandevVersion: version})
+	if err != nil {
+		log.Warn("Share handlers initialization failed (non-fatal)", zap.Error(err))
+		return nil
+	}
+	return h
+}
+
 // initSlackService wires up the Slack integration. Failures are non-fatal.
 // The agent runner is wired post-construction by main.go once hostutility +
 // utility services exist.
diff --git a/apps/backend/cmd/kandev/types.go b/apps/backend/cmd/kandev/types.go
index 5f4516891a..7015f43a4e 100644
--- a/apps/backend/cmd/kandev/types.go
+++ b/apps/backend/cmd/kandev/types.go
@@ -19,6 +19,7 @@ import (
 	"github.com/kandev/kandev/internal/slack"
 	sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite"
 	taskservice "github.com/kandev/kandev/internal/task/service"
+	"github.com/kandev/kandev/internal/task/share"
 	userservice "github.com/kandev/kandev/internal/user/service"
 	userstore "github.com/kandev/kandev/internal/user/store"
 	utilityservice "github.com/kandev/kandev/internal/utility/service"
@@ -54,6 +55,7 @@ type Services struct {
 	Jira         *jira.Service
 	Linear       *linear.Service
 	Slack        *slack.Service
+	Share        *share.HTTPHandlers
 	Office       *officeservice.Service
 	OfficeSvcs   *office.Services
 	// OrchScheduler is the office SchedulerIntegration constructed by
diff --git a/apps/backend/internal/github/client.go b/apps/backend/internal/github/client.go
index 7b0e042356..4637f53f6c 100644
--- a/apps/backend/internal/github/client.go
+++ b/apps/backend/internal/github/client.go
@@ -90,4 +90,12 @@ type Client interface {
 
 	// GetIssueState returns the state of a single issue ("open" or "closed").
 	GetIssueState(ctx context.Context, owner, repo string, number int) (string, error)
+
+	// CreateGist creates a new gist on the authenticated user's account.
+	// Set Public=false to create a secret (unlisted) gist.
+	CreateGist(ctx context.Context, in CreateGistInput) (*GistResponse, error)
+
+	// DeleteGist deletes a gist by ID. A 404 is wrapped in *GitHubAPIError
+	// so callers can distinguish "already gone" from transport failures.
+	DeleteGist(ctx context.Context, gistID string) error
 }
diff --git a/apps/backend/internal/github/controller_test.go b/apps/backend/internal/github/controller_test.go
index 205efc891c..f5a0b2a159 100644
--- a/apps/backend/internal/github/controller_test.go
+++ b/apps/backend/internal/github/controller_test.go
@@ -99,6 +99,10 @@ func (s *stubClient) GetIssueState(context.Context, string, string, int) (string
 func (s *stubClient) GetPRStatus(context.Context, string, string, int) (*PRStatus, error) {
 	return nil, nil
 }
+func (s *stubClient) CreateGist(context.Context, CreateGistInput) (*GistResponse, error) {
+	return nil, nil
+}
+func (s *stubClient) DeleteGist(context.Context, string) error { return nil }
 
 func newControllerTestLogger() *logger.Logger {
 	log, _ := logger.NewLogger(logger.LoggingConfig{
diff --git a/apps/backend/internal/github/gh_client.go b/apps/backend/internal/github/gh_client.go
index 3c8f22a118..e46a7bec2e 100644
--- a/apps/backend/internal/github/gh_client.go
+++ b/apps/backend/internal/github/gh_client.go
@@ -692,6 +692,65 @@ func (c *GHClient) ListRepoBranches(ctx context.Context, owner, repo string) ([]
 	return branches, nil
 }
 
+func (c *GHClient) CreateGist(ctx context.Context, in CreateGistInput) (*GistResponse, error) {
+	payload := struct {
+		Description string                 `json:"description,omitempty"`
+		Public      bool                   `json:"public"`
+		Files       map[string]gistFileDTO `json:"files"`
+	}{
+		Description: in.Description,
+		Public:      in.Public,
+		Files:       make(map[string]gistFileDTO, len(in.Files)),
+	}
+	for name, f := range in.Files {
+		payload.Files[name] = gistFileDTO(f)
+	}
+	body, err := json.Marshal(payload)
+	if err != nil {
+		return nil, fmt.Errorf("marshal gist payload: %w", err)
+	}
+	args := []string{"api", "gists",
+		"-X", "POST",
+		"-H", "Accept: " + githubAccept,
+		"--input", "-",
+	}
+	out, err := c.runWithStdin(ctx, body, args...)
+	if err != nil {
+		return nil, fmt.Errorf("create gist: %w", err)
+	}
+	var resp GistResponse
+	if err := json.Unmarshal([]byte(out), &resp); err != nil {
+		return nil, fmt.Errorf("decode gist response: %w", err)
+	}
+	return &resp, nil
+}
+
+func (c *GHClient) DeleteGist(ctx context.Context, gistID string) error {
+	if gistID == "" {
+		return fmt.Errorf("delete gist: empty id")
+	}
+	_, err := c.run(ctx, "api", "gists/"+gistID, "-X", "DELETE")
+	if err != nil {
+		// gh exits non-zero for HTTP errors; stderr contains the status
+		// line (e.g. "HTTP 404: Not Found"). Promote 404 to *GitHubAPIError
+		// so share.IsAlreadyGone matches consistently — PATClient.delete()
+		// already returns a typed error for the same case, and the share
+		// service uses errors.As to detect "gist already revoked upstream"
+		// and treat it as a soft success rather than a 502. Use isNotFoundErr
+		// so every gh-CLI 404 format ("HTTP 404", "404 Not Found", "status: 404")
+		// is recognised, not just the most common one.
+		if isNotFoundErr(err) {
+			return &GitHubAPIError{
+				StatusCode: http.StatusNotFound,
+				Endpoint:   "/gists/" + gistID,
+				Body:       err.Error(),
+			}
+		}
+		return fmt.Errorf("delete gist %s: %w", gistID, err)
+	}
+	return nil
+}
+
 const ghCLITimeout = 30 * time.Second
 
 // withDefaultGHTimeout applies a 30s timeout if the context has no deadline.
@@ -719,6 +778,23 @@ func (c *GHClient) run(ctx context.Context, args ...string) (string, error) {
 	return stdout.String(), nil
 }
 
+// runWithStdin is like run but pipes stdin into the gh CLI process.
+// Useful for `gh api --input -` calls where the body comes from JSON.
+func (c *GHClient) runWithStdin(ctx context.Context, stdin []byte, args ...string) (string, error) {
+	ctx, cancel := withDefaultGHTimeout(ctx)
+	defer cancel()
+	cmd := exec.CommandContext(ctx, "gh", args...)
+	cmd.Stdin = bytes.NewReader(stdin)
+	var stdout, stderr bytes.Buffer
+	cmd.Stdout = &stdout
+	cmd.Stderr = &stderr
+	if err := cmd.Run(); err != nil {
+		c.inspectRateStderr(args, stderr.String())
+		return stdout.String(), fmt.Errorf("gh %s: %w: %s", args[0], err, stderr.String())
+	}
+	return stdout.String(), nil
+}
+
 // ghRateLimitResponse mirrors the GET /rate_limit JSON shape so we can seed
 // the tracker on startup and after CLI failures without parsing prose.
 type ghRateLimitResponse struct {
diff --git a/apps/backend/internal/github/gists.go b/apps/backend/internal/github/gists.go
new file mode 100644
index 0000000000..325abc099e
--- /dev/null
+++ b/apps/backend/internal/github/gists.go
@@ -0,0 +1,22 @@
+package github
+
+import "time"
+
+// CreateGistInput is the payload for CreateGist.
+type CreateGistInput struct {
+	Description string
+	Public      bool
+	Files       map[string]GistFile
+}
+
+// GistFile is a single file entry inside a gist.
+type GistFile struct {
+	Content string
+}
+
+// GistResponse is the subset of the GitHub gist API response we care about.
+type GistResponse struct {
+	ID        string    `json:"id"`
+	HTMLURL   string    `json:"html_url"`
+	CreatedAt time.Time `json:"created_at"`
+}
diff --git a/apps/backend/internal/github/mock_client.go b/apps/backend/internal/github/mock_client.go
index 882051d898..15920a067b 100644
--- a/apps/backend/internal/github/mock_client.go
+++ b/apps/backend/internal/github/mock_client.go
@@ -74,6 +74,19 @@ type MockClient struct {
 	submittedReviews []submittedReview
 	mergedPRs        []mergedPR
 	mergeMethods     map[repoKey]RepoMergeMethods
+	gists            map[string]mockGist
+	deletedGists     []string
+	nextGistID       int
+}
+
+// mockGist captures a gist that was created via the mock client so tests
+// can inspect what would have been uploaded.
+type mockGist struct {
+	ID          string
+	Description string
+	Public      bool
+	Files       map[string]GistFile
+	HTMLURL     string
 }
 
 // NewMockClient creates a new MockClient with default values.
@@ -91,6 +104,7 @@ func NewMockClient() *MockClient {
 		files:         make(map[prKey][]PRFile),
 		commits:       make(map[prKey][]PRCommitInfo),
 		mergeMethods:  make(map[repoKey]RepoMergeMethods),
+		gists:         make(map[string]mockGist),
 	}
 }
 
@@ -311,6 +325,65 @@ func (m *MockClient) MergePR(_ context.Context, owner, repo string, number int,
 	return nil
 }
 
+func (m *MockClient) CreateGist(_ context.Context, in CreateGistInput) (*GistResponse, error) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	m.nextGistID++
+	id := fmt.Sprintf("mock-gist-%d", m.nextGistID)
+	htmlURL := "https://gist.github.com/" + m.user + "/" + id
+	files := make(map[string]GistFile, len(in.Files))
+	for k, v := range in.Files {
+		files[k] = v
+	}
+	m.gists[id] = mockGist{
+		ID:          id,
+		Description: in.Description,
+		Public:      in.Public,
+		Files:       files,
+		HTMLURL:     htmlURL,
+	}
+	return &GistResponse{
+		ID:        id,
+		HTMLURL:   htmlURL,
+		CreatedAt: time.Now().UTC(),
+	}, nil
+}
+
+func (m *MockClient) DeleteGist(_ context.Context, gistID string) error {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	if _, ok := m.gists[gistID]; !ok {
+		return &GitHubAPIError{
+			StatusCode: 404,
+			Endpoint:   "/gists/" + gistID,
+			Body:       "gist not found",
+		}
+	}
+	delete(m.gists, gistID)
+	m.deletedGists = append(m.deletedGists, gistID)
+	return nil
+}
+
+// Gists returns all currently-stored gists for test inspection.
+func (m *MockClient) Gists() map[string]mockGist {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+	out := make(map[string]mockGist, len(m.gists))
+	for k, v := range m.gists {
+		out[k] = v
+	}
+	return out
+}
+
+// DeletedGists returns the IDs of gists that were deleted, in call order.
+func (m *MockClient) DeletedGists() []string {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+	out := make([]string, len(m.deletedGists))
+	copy(out, m.deletedGists)
+	return out
+}
+
 // --- Setter methods for HTTP control endpoints ---
 
 // SetUser sets the authenticated username.
@@ -451,6 +524,9 @@ func (m *MockClient) Reset() {
 	m.submittedReviews = nil
 	m.mergedPRs = nil
 	m.mergeMethods = make(map[repoKey]RepoMergeMethods)
+	m.gists = make(map[string]mockGist)
+	m.deletedGists = nil
+	m.nextGistID = 0
 }
 
 // SubmittedReviews returns all recorded SubmitReview calls.
diff --git a/apps/backend/internal/github/mock_client_test.go b/apps/backend/internal/github/mock_client_test.go
index 6f677e8818..7bf3435fca 100644
--- a/apps/backend/internal/github/mock_client_test.go
+++ b/apps/backend/internal/github/mock_client_test.go
@@ -2,6 +2,7 @@ package github
 
 import (
 	"context"
+	"errors"
 	"testing"
 	"time"
 )
@@ -193,6 +194,54 @@ func TestMockClient_SubmitReview(t *testing.T) {
 	}
 }
 
+func TestMockClient_CreateAndDeleteGist(t *testing.T) {
+	m := NewMockClient()
+	ctx := context.Background()
+
+	resp, err := m.CreateGist(ctx, CreateGistInput{
+		Description: "snapshot",
+		Public:      false,
+		Files: map[string]GistFile{
+			"snapshot.json": {Content: `{"x":1}`},
+			"README.md":     {Content: "hi"},
+		},
+	})
+	if err != nil {
+		t.Fatalf("CreateGist error: %v", err)
+	}
+	if resp.ID == "" || resp.HTMLURL == "" {
+		t.Fatalf("expected non-empty id/url, got %+v", resp)
+	}
+
+	gists := m.Gists()
+	g, ok := gists[resp.ID]
+	if !ok {
+		t.Fatalf("gist %s not stored", resp.ID)
+	}
+	if g.Public {
+		t.Fatal("gist should default to secret (Public=false)")
+	}
+	if _, ok := g.Files["snapshot.json"]; !ok {
+		t.Fatal("snapshot.json missing from stored gist")
+	}
+
+	if err := m.DeleteGist(ctx, resp.ID); err != nil {
+		t.Fatalf("DeleteGist error: %v", err)
+	}
+	if _, ok := m.Gists()[resp.ID]; ok {
+		t.Fatal("gist still present after delete")
+	}
+	if got := m.DeletedGists(); len(got) != 1 || got[0] != resp.ID {
+		t.Fatalf("expected DeletedGists=[%q], got %v", resp.ID, got)
+	}
+
+	err = m.DeleteGist(ctx, resp.ID)
+	var apiErr *GitHubAPIError
+	if !errors.As(err, &apiErr) || apiErr.StatusCode != 404 {
+		t.Fatalf("expected 404 GitHubAPIError on second delete, got %v", err)
+	}
+}
+
 func TestMockClient_Reset(t *testing.T) {
 	m := NewMockClient()
 	ctx := context.Background()
diff --git a/apps/backend/internal/github/noop_client.go b/apps/backend/internal/github/noop_client.go
index 793a77f485..ee04b204c6 100644
--- a/apps/backend/internal/github/noop_client.go
+++ b/apps/backend/internal/github/noop_client.go
@@ -108,3 +108,11 @@ func (c *NoopClient) SearchPRsPaged(context.Context, string, string, int, int) (
 func (c *NoopClient) GetIssueState(context.Context, string, string, int) (string, error) {
 	return "", ErrNoClient
 }
+
+func (c *NoopClient) CreateGist(context.Context, CreateGistInput) (*GistResponse, error) {
+	return nil, ErrNoClient
+}
+
+func (c *NoopClient) DeleteGist(context.Context, string) error {
+	return ErrNoClient
+}
diff --git a/apps/backend/internal/github/pat_client.go b/apps/backend/internal/github/pat_client.go
index 68925261bd..bd342ebdb9 100644
--- a/apps/backend/internal/github/pat_client.go
+++ b/apps/backend/internal/github/pat_client.go
@@ -487,6 +487,42 @@ func (c *PATClient) MergePR(ctx context.Context, owner, repo string, number int,
 	return c.put(ctx, endpoint, jsonBody)
 }
 
+func (c *PATClient) CreateGist(ctx context.Context, in CreateGistInput) (*GistResponse, error) {
+	payload := struct {
+		Description string                 `json:"description,omitempty"`
+		Public      bool                   `json:"public"`
+		Files       map[string]gistFileDTO `json:"files"`
+	}{
+		Description: in.Description,
+		Public:      in.Public,
+		Files:       make(map[string]gistFileDTO, len(in.Files)),
+	}
+	for name, f := range in.Files {
+		payload.Files[name] = gistFileDTO(f)
+	}
+	body, err := json.Marshal(payload)
+	if err != nil {
+		return nil, fmt.Errorf("marshal gist payload: %w", err)
+	}
+	var resp GistResponse
+	if err := c.postJSON(ctx, "/gists", body, &resp); err != nil {
+		return nil, fmt.Errorf("create gist: %w", err)
+	}
+	return &resp, nil
+}
+
+func (c *PATClient) DeleteGist(ctx context.Context, gistID string) error {
+	if gistID == "" {
+		return fmt.Errorf("delete gist: empty id")
+	}
+	return c.delete(ctx, "/gists/"+gistID)
+}
+
+// gistFileDTO mirrors the GitHub API's per-file body shape.
+type gistFileDTO struct {
+	Content string `json:"content"`
+}
+
 // post makes an authenticated HTTP POST to the GitHub API.
 // Errors on non-2xx are returned as plain `fmt.Errorf` (not `*GitHubAPIError`),
 // so callers cannot recover the HTTP status via `errors.As`. Use `put` (or
@@ -516,6 +552,60 @@ func (c *PATClient) post(ctx context.Context, endpoint string, body []byte) erro
 	return nil
 }
 
+// postJSON sends a POST and decodes the response body into result.
+// 2xx with no body returns nil; 4xx/5xx returns a *GitHubAPIError.
+func (c *PATClient) postJSON(ctx context.Context, endpoint string, body []byte, result interface{}) error {
+	u := githubAPIBase + endpoint
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(body))
+	if err != nil {
+		return err
+	}
+	c.setGitHubHeaders(req)
+	req.Header.Set("Content-Type", "application/json")
+
+	resp, err := c.httpClient.Do(req)
+	if err != nil {
+		return fmt.Errorf("request POST %s: %w", endpoint, err)
+	}
+	defer func() { _ = resp.Body.Close() }()
+	c.recordRateHeaders(resp, endpoint)
+
+	if resp.StatusCode >= 400 {
+		respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
+		c.maybeMarkRateExhaustedFromBody(endpoint, resp.StatusCode, respBody)
+		return &GitHubAPIError{StatusCode: resp.StatusCode, Endpoint: endpoint, Body: string(respBody)}
+	}
+	if result == nil {
+		return nil
+	}
+	return json.NewDecoder(resp.Body).Decode(result)
+}
+
+// delete sends a DELETE request. 2xx and 404 both return nil-or-typed-error per caller intent.
+// Here we return a typed error on any non-2xx so callers can inspect for 404.
+func (c *PATClient) delete(ctx context.Context, endpoint string) error {
+	u := githubAPIBase + endpoint
+	req, err := http.NewRequestWithContext(ctx, http.MethodDelete, u, nil)
+	if err != nil {
+		return err
+	}
+	c.setGitHubHeaders(req)
+
+	resp, err := c.httpClient.Do(req)
+	if err != nil {
+		return fmt.Errorf("request DELETE %s: %w", endpoint, err)
+	}
+	defer func() { _ = resp.Body.Close() }()
+	c.recordRateHeaders(resp, endpoint)
+
+	if resp.StatusCode >= 400 {
+		respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
+		c.maybeMarkRateExhaustedFromBody(endpoint, resp.StatusCode, respBody)
+		return &GitHubAPIError{StatusCode: resp.StatusCode, Endpoint: endpoint, Body: string(respBody)}
+	}
+	return nil
+}
+
 func (c *PATClient) put(ctx context.Context, endpoint string, body []byte) error {
 	u := githubAPIBase + endpoint
 	req, err := http.NewRequestWithContext(ctx, http.MethodPut, u, bytes.NewReader(body))
diff --git a/apps/backend/internal/task/share/backend.go b/apps/backend/internal/task/share/backend.go
new file mode 100644
index 0000000000..5a5ed6d99e
--- /dev/null
+++ b/apps/backend/internal/task/share/backend.go
@@ -0,0 +1,44 @@
+package share
+
+import (
+	"context"
+	"errors"
+	"net/http"
+
+	"github.com/kandev/kandev/internal/github"
+)
+
+// ErrSnapshotTooLarge is returned when a snapshot exceeds the backend's
+// per-file size cap. The HTTP handler maps this to 413.
+var ErrSnapshotTooLarge = errors.New("snapshot exceeds maximum size")
+
+// Backend is the contract every storage backend must implement. The snapshot
+// has already been redacted by the caller; the backend is responsible only
+// for transport and persistence.
+type Backend interface {
+	// Name returns the backend identifier persisted in task_shares.backend.
+	Name() string
+
+	// Upload publishes the snapshot and returns the backend-specific
+	// identifier (e.g. gist ID) and the public URL where it can be viewed.
+	Upload(ctx context.Context, snap *Snapshot) (externalID, externalURL string, err error)
+
+	// Delete removes a previously-uploaded snapshot. Backends MAY return a
+	// "not found" error which the service detects via IsAlreadyGone.
+	Delete(ctx context.Context, externalID string) error
+}
+
+// IsAlreadyGone reports whether err signals the backend resource no longer
+// exists. The service uses this to treat repeated revoke calls as success.
+// Currently scoped to GitHub-API 404s but the helper lives here so future
+// backends can extend it without leaking package boundaries.
+func IsAlreadyGone(err error) bool {
+	if err == nil {
+		return false
+	}
+	var apiErr *github.GitHubAPIError
+	if errors.As(err, &apiErr) {
+		return apiErr.StatusCode == http.StatusNotFound
+	}
+	return false
+}
diff --git a/apps/backend/internal/task/share/builder.go b/apps/backend/internal/task/share/builder.go
new file mode 100644
index 0000000000..b90c783d43
--- /dev/null
+++ b/apps/backend/internal/task/share/builder.go
@@ -0,0 +1,279 @@
+package share
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/kandev/kandev/internal/sysprompt"
+	"github.com/kandev/kandev/internal/task/models"
+)
+
+// ErrSessionNotShareable is returned when BuildSnapshot is asked to snapshot
+// a session that has not produced any conversation yet (CREATED / STARTING).
+// The HTTP handler maps this to 409.
+var ErrSessionNotShareable = errors.New("session has no shareable content yet")
+
+// TaskReader is the narrow slice of the task repository BuildSnapshot needs.
+// Keeping it small lets us stub it in unit tests without pulling in the full
+// task repo interface.
+type TaskReader interface {
+	GetTask(ctx context.Context, id string) (*models.Task, error)
+	GetTaskSession(ctx context.Context, id string) (*models.TaskSession, error)
+	ListMessages(ctx context.Context, sessionID string) ([]*models.Message, error)
+}
+
+// BuildSnapshot loads the task, session, and messages for the given session
+// and returns a redacted snapshot. The kandevVersion is recorded in the
+// snapshot for forward debugging but is otherwise opaque.
+//
+// Sessions in CREATED or STARTING state are rejected because they predate
+// any agent output. Every other state — RUNNING, IDLE, WAITING_FOR_INPUT,
+// COMPLETED, FAILED, CANCELLED — produces a valid snapshot of whatever
+// conversation has happened so far.
+func BuildSnapshot(ctx context.Context, repo TaskReader, taskSessionID, kandevVersion string) (*Snapshot, error) {
+	session, err := repo.GetTaskSession(ctx, taskSessionID)
+	if err != nil {
+		return nil, fmt.Errorf("load session: %w", err)
+	}
+	if session == nil {
+		return nil, fmt.Errorf("load session: %w", ErrSessionNotShareable)
+	}
+	if !isShareableState(session.State) {
+		return nil, ErrSessionNotShareable
+	}
+
+	task, err := repo.GetTask(ctx, session.TaskID)
+	if err != nil {
+		return nil, fmt.Errorf("load task: %w", err)
+	}
+
+	messages, err := repo.ListMessages(ctx, taskSessionID)
+	if err != nil {
+		return nil, fmt.Errorf("load messages: %w", err)
+	}
+
+	red := NewRedactor(workspaceRootsFor(session)...)
+	snap := &Snapshot{
+		Version:       SnapshotVersion,
+		KandevVersion: kandevVersion,
+		ExportedAt:    time.Now().UTC(),
+		Task:          taskMeta(task),
+		Session:       sessionMeta(session),
+		Messages:      mapMessages(messages, red),
+	}
+	snap.Redaction.AppliedRules = red.Applied()
+	return snap, nil
+}
+
+// isShareableState mirrors the frontend gate: any session that has
+// progressed past the pre-history states (CREATED, STARTING) is fair game.
+func isShareableState(s models.TaskSessionState) bool {
+	switch s {
+	case models.TaskSessionStateCreated, models.TaskSessionStateStarting:
+		return false
+	}
+	return true
+}
+
+func taskMeta(t *models.Task) TaskMeta {
+	if t == nil {
+		return TaskMeta{}
+	}
+	return TaskMeta{
+		Title:        t.Title,
+		WorkflowStep: t.WorkflowStepID,
+	}
+}
+
+func sessionMeta(s *models.TaskSession) SessionMeta {
+	return SessionMeta{
+		AgentType:    snapshotString(s.AgentProfileSnapshot, "agent_type"),
+		Model:        snapshotString(s.AgentProfileSnapshot, "model"),
+		ExecutorType: snapshotString(s.ExecutorSnapshot, "type"),
+		StartedAt:    s.StartedAt,
+		CompletedAt:  s.CompletedAt,
+	}
+}
+
+// workspaceRootsFor returns every filesystem root the redactor should rewrite
+// to repo-relative form. Multi-repo sessions carry multiple worktrees, so we
+// emit each WorktreePath plus the session's WorkspacePath fallback — the
+// redactor sorts them longest-first internally to handle nested roots.
+func workspaceRootsFor(s *models.TaskSession) []string {
+	roots := make([]string, 0, len(s.Worktrees)+1)
+	for _, w := range s.Worktrees {
+		if w != nil && w.WorktreePath != "" {
+			roots = append(roots, w.WorktreePath)
+		}
+	}
+	if s.WorkspacePath != "" {
+		roots = append(roots, s.WorkspacePath)
+	}
+	return roots
+}
+
+// snapshotString extracts a string field from one of the *Snapshot map
+// metadata fields stored on TaskSession. Returns "" if the field is missing
+// or is not a string.
+func snapshotString(m map[string]interface{}, key string) string {
+	if m == nil {
+		return ""
+	}
+	v, ok := m[key]
+	if !ok {
+		return ""
+	}
+	s, _ := v.(string)
+	return s
+}
+
+// mapMessages converts the live message rows into snapshot Messages.
+// Hidden system content (wrapped in ) is stripped per the
+// existing Message.ToAPI() convention before redaction.
+func mapMessages(in []*models.Message, red *Redactor) []Message {
+	out := make([]Message, 0, len(in))
+	for _, m := range in {
+		role := roleForAuthor(m.AuthorType, m.Type)
+		blocks := blocksForMessage(m, red)
+		if len(blocks) == 0 {
+			continue
+		}
+		out = append(out, Message{
+			Role:   role,
+			Ts:     m.CreatedAt,
+			Blocks: blocks,
+		})
+	}
+	return out
+}
+
+func roleForAuthor(author models.MessageAuthorType, mt models.MessageType) string {
+	switch author {
+	case models.MessageAuthorUser:
+		return roleUser
+	case models.MessageAuthorAgent:
+		return roleAssistant
+	}
+	if mt == models.MessageTypeStatus || mt == models.MessageTypeLog || mt == models.MessageTypeError {
+		return roleSystem
+	}
+	return roleSystem
+}
+
+// blocksForMessage decides whether a Message is shareable conversation
+// content and converts it into the snapshot Blocks for that role. We
+// whitelist what gets through rather than blacklist noise — that way new
+// internal message types (tool_search, agent_plan, todo, mock-agent stderr
+// captures, …) don't quietly leak into shared snapshots.
+//
+// What gets through:
+//   - User messages of any type → text block (the user's intent is always
+//     part of the conversation, even if the runtime tagged it oddly).
+//   - Agent messages with no explicit type, "message", or "content"
+//     → text block (regular agent prose).
+//   - Agent messages whose type starts with "tool_" (tool_call, tool_edit,
+//     tool_read, tool_execute, tool_search, …) → collapsed tool block.
+//
+// Everything else — status, progress, log, error, thinking, agent_plan,
+// todo, permission_request, clarification_request, script_execution, plus
+// any future type we don't recognise — is dropped.
+func blocksForMessage(m *models.Message, red *Redactor) []Block {
+	if m.AuthorType == models.MessageAuthorUser {
+		return textBlockOrNil(red, m.Content)
+	}
+	t := string(m.Type)
+	if t == "" || t == string(models.MessageTypeMessage) || t == string(models.MessageTypeContent) {
+		return textBlockOrNil(red, m.Content)
+	}
+	if strings.HasPrefix(t, "tool_") {
+		return []Block{toolCallBlock(m, red)}
+	}
+	return nil
+}
+
+// textBlockOrNil returns a single text block, or nil if the content is empty
+// after trimming. System-injected blocks wrapped in  tags
+// (the system prompt the runtime prepends to user turns) are stripped first
+// — those carry implementation details that should never appear in a public
+// share. After stripping, if nothing is left we drop the message entirely.
+func textBlockOrNil(red *Redactor, content string) []Block {
+	stripped := sysprompt.StripSystemContent(content)
+	text := strings.TrimSpace(stripped)
+	if text == "" {
+		return nil
+	}
+	return []Block{{Kind: blockKindText, Text: red.String(text)}}
+}
+
+// toolCallBlock builds a "tool_call" Block from a tool-call message. The
+// message Content carries a human-readable summary; the Args, when present
+// in Metadata, are redacted as a top-level JSON object so RuleEnvVars fires
+// for shell payloads.
+func toolCallBlock(m *models.Message, red *Redactor) Block {
+	b := Block{
+		Kind:     blockKindToolCall,
+		Text:     red.String(strings.TrimSpace(m.Content)),
+		ToolName: toolNameFor(m),
+	}
+	if raw, ok := toolArgsFor(m); ok {
+		b.Args = red.JSON(raw)
+	}
+	return b
+}
+
+// toolNameFor picks the tool label. Production messages don't carry a
+// "tool_name" metadata key — service_messages.go writes the normalized
+// *streams.NormalizedPayload under metadata["normalized"] and the tool kind
+// ends up on Message.Type ("tool_read", "tool_execute", …). Strip the
+// "tool_" prefix from Type to get a useful label ("read", "execute", "edit").
+// The debug-fixture replay path *does* set metadata["tool_name"], so prefer
+// that when present for backward compatibility.
+func toolNameFor(m *models.Message) string {
+	if name := metaString(m.Metadata, "tool_name"); name != "" {
+		return name
+	}
+	return strings.TrimPrefix(string(m.Type), "tool_")
+}
+
+// toolArgsFor picks the args JSON payload. Production stores the typed tool
+// payload under metadata["normalized"]; the debug-fixture replay path stores
+// it under metadata["args"]. Prefer "args" so existing fixtures keep their
+// shape, fall back to "normalized" so real shares have something to redact.
+func toolArgsFor(m *models.Message) (json.RawMessage, bool) {
+	if raw, ok := metaJSON(m.Metadata, "args"); ok {
+		return raw, true
+	}
+	return metaJSON(m.Metadata, "normalized")
+}
+
+func metaString(m map[string]interface{}, key string) string {
+	if m == nil {
+		return ""
+	}
+	if v, ok := m[key]; ok {
+		s, _ := v.(string)
+		return s
+	}
+	return ""
+}
+
+// metaJSON marshals an arbitrary metadata value back to a JSON RawMessage so
+// it can be redacted via Redactor.JSON. Returns ok=false if the key is missing.
+func metaJSON(m map[string]interface{}, key string) (json.RawMessage, bool) {
+	if m == nil {
+		return nil, false
+	}
+	v, ok := m[key]
+	if !ok {
+		return nil, false
+	}
+	raw, err := json.Marshal(v)
+	if err != nil {
+		return nil, false
+	}
+	return raw, true
+}
diff --git a/apps/backend/internal/task/share/builder_test.go b/apps/backend/internal/task/share/builder_test.go
new file mode 100644
index 0000000000..24832face3
--- /dev/null
+++ b/apps/backend/internal/task/share/builder_test.go
@@ -0,0 +1,389 @@
+package share
+
+import (
+	"context"
+	"errors"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/kandev/kandev/internal/task/models"
+)
+
+// stubReader implements TaskReader for unit tests.
+type stubReader struct {
+	task     *models.Task
+	session  *models.TaskSession
+	messages []*models.Message
+}
+
+func (s *stubReader) GetTask(_ context.Context, _ string) (*models.Task, error) {
+	return s.task, nil
+}
+func (s *stubReader) GetTaskSession(_ context.Context, _ string) (*models.TaskSession, error) {
+	return s.session, nil
+}
+func (s *stubReader) ListMessages(_ context.Context, _ string) ([]*models.Message, error) {
+	return s.messages, nil
+}
+
+func TestBuildSnapshot_AllowsRunningAndOtherPostStartStates(t *testing.T) {
+	t.Parallel()
+	for _, state := range []models.TaskSessionState{
+		models.TaskSessionStateRunning,
+		models.TaskSessionStateIdle,
+		models.TaskSessionStateWaitingForInput,
+		models.TaskSessionStateCompleted,
+		models.TaskSessionStateFailed,
+		models.TaskSessionStateCancelled,
+	} {
+		state := state
+		t.Run(string(state), func(t *testing.T) {
+			r := &stubReader{
+				task:    &models.Task{ID: "t-1", Title: "Hi"},
+				session: &models.TaskSession{ID: "s-1", TaskID: "t-1", State: state},
+				messages: []*models.Message{
+					{ID: "m-1", AuthorType: models.MessageAuthorUser, Type: models.MessageTypeMessage, Content: "hi"},
+				},
+			}
+			snap, err := BuildSnapshot(context.Background(), r, "s-1", "v")
+			if err != nil {
+				t.Fatalf("state %q: unexpected error: %v", state, err)
+			}
+			if snap == nil || len(snap.Messages) == 0 {
+				t.Fatalf("state %q: expected non-empty snapshot", state)
+			}
+		})
+	}
+}
+
+func TestBuildSnapshot_RejectsPreHistoryStates(t *testing.T) {
+	t.Parallel()
+	for _, state := range []models.TaskSessionState{
+		models.TaskSessionStateCreated,
+		models.TaskSessionStateStarting,
+	} {
+		state := state
+		t.Run(string(state), func(t *testing.T) {
+			r := &stubReader{
+				task:    &models.Task{ID: "t-1", Title: "Hi"},
+				session: &models.TaskSession{ID: "s-1", TaskID: "t-1", State: state},
+			}
+			_, err := BuildSnapshot(context.Background(), r, "s-1", "v")
+			if !errors.Is(err, ErrSessionNotShareable) {
+				t.Fatalf("state %q: expected ErrSessionNotShareable, got %v", state, err)
+			}
+		})
+	}
+}
+
+func TestBuildSnapshot_BuildsBasicConversation(t *testing.T) {
+	t.Parallel()
+	completed := time.Now().UTC()
+	r := &stubReader{
+		task: &models.Task{ID: "t-1", Title: "Investigate flaky test", WorkflowStepID: "step-debug"},
+		session: &models.TaskSession{
+			ID: "s-1", TaskID: "t-1", State: models.TaskSessionStateCompleted,
+			StartedAt:   completed.Add(-time.Minute),
+			CompletedAt: &completed,
+			AgentProfileSnapshot: map[string]interface{}{
+				"agent_type": "claude-acp",
+				"model":      "claude-opus-4-7",
+			},
+			ExecutorSnapshot: map[string]interface{}{"type": "local_docker"},
+			WorkspacePath:    "/Users/foo/proj",
+		},
+		messages: []*models.Message{
+			{
+				ID: "m-1", TaskSessionID: "s-1", AuthorType: models.MessageAuthorUser,
+				Type: models.MessageTypeMessage, Content: "Hello",
+				CreatedAt: completed.Add(-30 * time.Second),
+			},
+			{
+				ID: "m-2", TaskSessionID: "s-1", AuthorType: models.MessageAuthorAgent,
+				Type: models.MessageTypeContent, Content: "Hi there",
+				CreatedAt: completed.Add(-25 * time.Second),
+			},
+		},
+	}
+	snap, err := BuildSnapshot(context.Background(), r, "s-1", "v-test")
+	if err != nil {
+		t.Fatalf("build: %v", err)
+	}
+	if snap.Version != SnapshotVersion {
+		t.Fatalf("expected version %d, got %d", SnapshotVersion, snap.Version)
+	}
+	if snap.Task.Title != "Investigate flaky test" {
+		t.Fatalf("task.title = %q", snap.Task.Title)
+	}
+	if snap.Session.AgentType != "claude-acp" || snap.Session.Model != "claude-opus-4-7" || snap.Session.ExecutorType != "local_docker" {
+		t.Fatalf("session metadata not populated: %+v", snap.Session)
+	}
+	if len(snap.Messages) != 2 {
+		t.Fatalf("expected 2 messages, got %d", len(snap.Messages))
+	}
+	if snap.Messages[0].Role != roleUser || snap.Messages[1].Role != roleAssistant {
+		t.Fatalf("roles wrong: %v", snap.Messages)
+	}
+	if snap.Messages[0].Blocks[0].Kind != blockKindText {
+		t.Fatalf("expected text block, got %q", snap.Messages[0].Blocks[0].Kind)
+	}
+}
+
+func TestBuildSnapshot_RedactsToolCallEnvAndPaths(t *testing.T) {
+	t.Parallel()
+	completed := time.Now().UTC()
+	r := &stubReader{
+		task: &models.Task{ID: "t-1", Title: "Shell run"},
+		session: &models.TaskSession{
+			ID: "s-1", TaskID: "t-1", State: models.TaskSessionStateCompleted,
+			StartedAt: completed, CompletedAt: &completed,
+			WorkspacePath: "/Users/foo/proj",
+		},
+		messages: []*models.Message{
+			{
+				ID: "m-1", TaskSessionID: "s-1", AuthorType: models.MessageAuthorAgent,
+				Type:    models.MessageTypeToolExecute,
+				Content: "ran ls in /Users/foo/proj/src",
+				Metadata: map[string]interface{}{
+					"tool_name": "shell",
+					"args": map[string]interface{}{
+						"cmd": "ls",
+						"env": map[string]interface{}{"SECRET": "abc"},
+					},
+				},
+				CreatedAt: completed,
+			},
+		},
+	}
+	snap, err := BuildSnapshot(context.Background(), r, "s-1", "v")
+	if err != nil {
+		t.Fatalf("build: %v", err)
+	}
+	if len(snap.Messages) != 1 {
+		t.Fatalf("expected 1 message, got %d", len(snap.Messages))
+	}
+	b := snap.Messages[0].Blocks[0]
+	if b.Kind != blockKindToolCall {
+		t.Fatalf("expected tool_call block, got %q", b.Kind)
+	}
+	if strings.Contains(b.Text, "/Users/foo/proj") {
+		t.Fatalf("expected workspace root rewritten, got %q", b.Text)
+	}
+	if strings.Contains(string(b.Args), "SECRET") {
+		t.Fatalf("env should be redacted from args, got %s", string(b.Args))
+	}
+	if !strings.Contains(string(b.Args), `"cmd":"ls"`) {
+		t.Fatalf("cmd preserved expected, got %s", string(b.Args))
+	}
+	got := snap.Redaction.AppliedRules
+	wantHas := map[string]bool{RuleEnvVars: false, RuleAbsPath: false}
+	for _, r := range got {
+		if _, ok := wantHas[r]; ok {
+			wantHas[r] = true
+		}
+	}
+	for k, present := range wantHas {
+		if !present {
+			t.Fatalf("expected applied rule %q, got %v", k, got)
+		}
+	}
+}
+
+// TestBuildSnapshot_ToolCall_ProductionMetadataShape exercises the metadata
+// shape that real sessions produce: no "tool_name" key, args under
+// metadata["normalized"] as the NormalizedPayload JSON, message Type set to
+// "tool_". The earlier shape ("tool_name"/"args" keys) only exists in
+// the debug fixture loader.
+func TestBuildSnapshot_ToolCall_ProductionMetadataShape(t *testing.T) {
+	t.Parallel()
+	completed := time.Now().UTC()
+	r := &stubReader{
+		task: &models.Task{ID: "t-1", Title: "Real session"},
+		session: &models.TaskSession{
+			ID: "s-1", TaskID: "t-1", State: models.TaskSessionStateCompleted,
+			StartedAt: completed, CompletedAt: &completed,
+			WorkspacePath: "/workspace/proj",
+		},
+		messages: []*models.Message{
+			{
+				ID: "m-1", TaskSessionID: "s-1", AuthorType: models.MessageAuthorAgent,
+				Type:    models.MessageTypeToolRead,
+				Content: "read /workspace/proj/src/main.go",
+				Metadata: map[string]interface{}{
+					"normalized": map[string]interface{}{
+						"kind": "read_file",
+						"read_file": map[string]interface{}{
+							"path": "/workspace/proj/src/main.go",
+						},
+					},
+				},
+				CreatedAt: completed,
+			},
+		},
+	}
+	snap, err := BuildSnapshot(context.Background(), r, "s-1", "v")
+	if err != nil {
+		t.Fatalf("build: %v", err)
+	}
+	b := snap.Messages[0].Blocks[0]
+	if b.Kind != blockKindToolCall {
+		t.Fatalf("expected tool_call block, got %q", b.Kind)
+	}
+	// "tool_" prefix stripped from m.Type to produce a useful label.
+	if b.ToolName != "read" {
+		t.Fatalf("expected ToolName=read, got %q", b.ToolName)
+	}
+	// Args populated from metadata["normalized"] and redacted.
+	if len(b.Args) == 0 {
+		t.Fatalf("expected Args populated from normalized payload, got empty")
+	}
+	if strings.Contains(string(b.Args), "/workspace/proj") {
+		t.Fatalf("expected abs-path redacted in args, got %s", string(b.Args))
+	}
+	if strings.Contains(b.Text, "/workspace/proj") {
+		t.Fatalf("expected abs-path redacted in text, got %q", b.Text)
+	}
+}
+
+func TestBuildSnapshot_FiltersNoiseTypes(t *testing.T) {
+	t.Parallel()
+	completed := time.Now().UTC()
+	r := &stubReader{
+		task: &models.Task{ID: "t-1", Title: "T"},
+		session: &models.TaskSession{
+			ID: "s-1", TaskID: "t-1", State: models.TaskSessionStateCompleted,
+			StartedAt: completed, CompletedAt: &completed,
+		},
+		messages: []*models.Message{
+			// Agent noise types — must all be dropped.
+			{ID: "n-1", AuthorType: models.MessageAuthorAgent, Type: models.MessageTypeLog, Content: "mock-agent: registered ACP MCP server kandev at http://localhost:41027/sse", CreatedAt: completed},
+			{ID: "n-2", AuthorType: models.MessageAuthorAgent, Type: models.MessageTypeStatus, Content: "started", CreatedAt: completed},
+			{ID: "n-3", AuthorType: models.MessageAuthorAgent, Type: models.MessageTypeError, Content: "boom", CreatedAt: completed},
+			{ID: "n-4", AuthorType: models.MessageAuthorAgent, Type: models.MessageTypeThinking, Content: "hmm", CreatedAt: completed},
+			{ID: "n-5", AuthorType: models.MessageAuthorAgent, Type: models.MessageTypeAgentPlan, Content: "plan", CreatedAt: completed},
+			{ID: "n-6", AuthorType: models.MessageAuthorAgent, Type: models.MessageTypeTodo, Content: "todo list", CreatedAt: completed},
+			{ID: "n-7", AuthorType: models.MessageAuthorAgent, Type: models.MessageTypeScriptExecution, Content: "script", CreatedAt: completed},
+			{ID: "n-8", AuthorType: models.MessageAuthorAgent, Type: "completely_unknown_type", Content: "future leak", CreatedAt: completed},
+			// Legit content — must survive.
+			{ID: "ok-1", AuthorType: models.MessageAuthorUser, Type: models.MessageTypeMessage, Content: "real question", CreatedAt: completed},
+			{ID: "ok-2", AuthorType: models.MessageAuthorAgent, Type: models.MessageTypeMessage, Content: "real answer", CreatedAt: completed},
+			{ID: "ok-3", AuthorType: models.MessageAuthorAgent, Type: "tool_search", Content: "Search for \"import \"", Metadata: map[string]interface{}{"tool_name": "grep"}, CreatedAt: completed},
+		},
+	}
+	snap, err := BuildSnapshot(context.Background(), r, "s-1", "v")
+	if err != nil {
+		t.Fatalf("build: %v", err)
+	}
+	if len(snap.Messages) != 3 {
+		t.Fatalf("expected only 3 surviving messages (user prose, agent prose, tool_search pill), got %d:\n%+v", len(snap.Messages), snap.Messages)
+	}
+	// Make sure none of the noise content leaked in.
+	for _, m := range snap.Messages {
+		for _, b := range m.Blocks {
+			if strings.Contains(b.Text, "mock-agent:") {
+				t.Fatal("mock-agent log line leaked into snapshot")
+			}
+			if strings.Contains(b.Text, "hmm") || strings.Contains(b.Text, "todo list") {
+				t.Fatalf("internal type leaked: %q", b.Text)
+			}
+		}
+	}
+	// tool_search must produce a tool_call block, not text.
+	last := snap.Messages[len(snap.Messages)-1]
+	if last.Blocks[0].Kind != blockKindToolCall {
+		t.Fatalf("tool_search should render as tool_call block, got kind=%q", last.Blocks[0].Kind)
+	}
+}
+
+func TestBuildSnapshot_StripsKandevSystemBlocks(t *testing.T) {
+	t.Parallel()
+	completed := time.Now().UTC()
+	r := &stubReader{
+		task: &models.Task{ID: "t-1", Title: "T"},
+		session: &models.TaskSession{
+			ID: "s-1", TaskID: "t-1", State: models.TaskSessionStateCompleted,
+			StartedAt: completed, CompletedAt: &completed,
+		},
+		messages: []*models.Message{
+			// First-turn prompt with system context wrapper — only the
+			// visible bit "real question" should survive.
+			{
+				ID: "u-1", AuthorType: models.MessageAuthorUser, Type: models.MessageTypeMessage,
+				Content:   "\nMCP context goes here\nMore secret stuff\n\nreal question",
+				CreatedAt: completed,
+			},
+			// Pure system payload — should be dropped entirely after stripping.
+			{
+				ID: "u-2", AuthorType: models.MessageAuthorUser, Type: models.MessageTypeMessage,
+				Content:   "only system stuff",
+				CreatedAt: completed,
+			},
+		},
+	}
+	snap, err := BuildSnapshot(context.Background(), r, "s-1", "v")
+	if err != nil {
+		t.Fatalf("build: %v", err)
+	}
+	if len(snap.Messages) != 1 {
+		t.Fatalf("expected 1 surviving message after system strip, got %d", len(snap.Messages))
+	}
+	body := snap.Messages[0].Blocks[0].Text
+	if strings.Contains(body, "kandev-system") || strings.Contains(body, "MCP context") || strings.Contains(body, "secret stuff") {
+		t.Fatalf("system content leaked into snapshot: %q", body)
+	}
+	if body != "real question" {
+		t.Fatalf("expected only the visible bit to remain, got %q", body)
+	}
+}
+
+func TestBuildSnapshot_UserMessageWithUnusualTypeStillIncluded(t *testing.T) {
+	t.Parallel()
+	completed := time.Now().UTC()
+	r := &stubReader{
+		task: &models.Task{ID: "t-1", Title: "T"},
+		session: &models.TaskSession{
+			ID: "s-1", TaskID: "t-1", State: models.TaskSessionStateCompleted,
+			StartedAt: completed, CompletedAt: &completed,
+		},
+		messages: []*models.Message{
+			// A user message tagged with a weird type still represents the
+			// user's intent — keep it.
+			{ID: "u-1", AuthorType: models.MessageAuthorUser, Type: "some_weird_type", Content: "important question", CreatedAt: completed},
+		},
+	}
+	snap, err := BuildSnapshot(context.Background(), r, "s-1", "v")
+	if err != nil {
+		t.Fatalf("build: %v", err)
+	}
+	if len(snap.Messages) != 1 {
+		t.Fatalf("expected 1 user message, got %d", len(snap.Messages))
+	}
+	if snap.Messages[0].Role != roleUser {
+		t.Fatalf("expected user role, got %q", snap.Messages[0].Role)
+	}
+}
+
+func TestBuildSnapshot_SkipsStatusAndEmptyMessages(t *testing.T) {
+	t.Parallel()
+	completed := time.Now().UTC()
+	r := &stubReader{
+		task: &models.Task{ID: "t-1", Title: "T"},
+		session: &models.TaskSession{
+			ID: "s-1", TaskID: "t-1", State: models.TaskSessionStateCompleted,
+			StartedAt: completed, CompletedAt: &completed,
+		},
+		messages: []*models.Message{
+			{ID: "m-1", AuthorType: models.MessageAuthorAgent, Type: models.MessageTypeStatus, Content: "started", CreatedAt: completed},
+			{ID: "m-2", AuthorType: models.MessageAuthorAgent, Type: models.MessageTypeMessage, Content: "  ", CreatedAt: completed},
+			{ID: "m-3", AuthorType: models.MessageAuthorUser, Type: models.MessageTypeMessage, Content: "hi", CreatedAt: completed},
+		},
+	}
+	snap, err := BuildSnapshot(context.Background(), r, "s-1", "v")
+	if err != nil {
+		t.Fatalf("build: %v", err)
+	}
+	if len(snap.Messages) != 1 || snap.Messages[0].Role != roleUser {
+		t.Fatalf("expected 1 user message after pruning, got %+v", snap.Messages)
+	}
+}
diff --git a/apps/backend/internal/task/share/gist_backend.go b/apps/backend/internal/task/share/gist_backend.go
new file mode 100644
index 0000000000..e873522475
--- /dev/null
+++ b/apps/backend/internal/task/share/gist_backend.go
@@ -0,0 +1,161 @@
+package share
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/kandev/kandev/internal/github"
+)
+
+// GistMaxBytes is a conservative cap on a single snapshot.json file. GitHub
+// caps individual gist files at 100 MiB but 10 MiB is plenty for any
+// reasonable conversation and avoids accidentally pushing huge payloads.
+const GistMaxBytes = 10 * 1024 * 1024
+
+// GistBackend uploads snapshots to GitHub Gists on the authenticated user's
+// account. Created gists are always secret (unlisted); anyone with the URL
+// can view them but they are not crawled.
+type GistBackend struct {
+	client github.Client
+}
+
+// NewGistBackend returns a Backend that uses the given github.Client.
+func NewGistBackend(client github.Client) *GistBackend {
+	return &GistBackend{client: client}
+}
+
+// Name implements Backend.
+func (b *GistBackend) Name() string { return BackendGitHubGist }
+
+// Upload marshals the snapshot to JSON, builds the rendered share.html and
+// a README, then creates a secret gist with all three. The returned URL is
+// the rendered view served via gistpreview.github.io — that's the link
+// users share. The gist itself is preserved in the database via the
+// externalID so we can still delete it on revoke.
+func (b *GistBackend) Upload(ctx context.Context, snap *Snapshot) (string, string, error) {
+	body, err := json.MarshalIndent(snap, "", "  ")
+	if err != nil {
+		return "", "", fmt.Errorf("marshal snapshot: %w", err)
+	}
+	if len(body) > GistMaxBytes {
+		return "", "", fmt.Errorf("%w: %d bytes > %d", ErrSnapshotTooLarge, len(body), GistMaxBytes)
+	}
+	resp, err := b.client.CreateGist(ctx, github.CreateGistInput{
+		Description: gistDescription(snap),
+		Public:      false,
+		Files: map[string]github.GistFile{
+			// share.html sorts first alphabetically in the gist file list,
+			// which puts the rendered view at the top of the gist page too.
+			"share.html":    {Content: BuildShareHTML(snap)},
+			"snapshot.json": {Content: string(body)},
+			// README is built with empty renderedURL — the user's primary
+			// link goes to the rendered view; the README is a fallback for
+			// folks who land on the gist directly.
+			"README.md": {Content: BuildGistREADME(snap, "")},
+		},
+	})
+	if err != nil {
+		return "", "", fmt.Errorf("create gist: %w", err)
+	}
+	rendered := renderedURLForGist(resp.HTMLURL)
+	if rendered == "" {
+		// Fall back to the raw gist URL if we couldn't parse a gist ID —
+		// not pretty, but at least the user has a working link.
+		rendered = resp.HTMLURL
+	}
+	return resp.ID, rendered, nil
+}
+
+// renderedURLForGist returns the public rendering URL we hand to the user
+// for a given gist. We route through gistpreview.github.io, a static
+// GitHub-Pages site that fetches the gist via the public API and renders
+// an HTML file client-side. We use it instead of raw.githack.com so
+// visitors don't hit the "One more step" anti-phishing interstitial.
+//
+// Critically: we append "/share.html" to the URL because gistpreview's
+// file-picker logic picks the first iterated file unless one is named
+// "index.html". Our gists contain README.md, share.html, snapshot.json —
+// without the explicit suffix, gistpreview picks README.md (alphabetical
+// first), renders it raw, and the page appears unstyled. The explicit
+// filename pins it to share.html.
+//
+//	https://gist.github.com//  →  https://gistpreview.github.io/?/share.html
+//	https://gist.github.com/          →  https://gistpreview.github.io/?/share.html
+//
+// Returns "" if the URL doesn't look like a gist URL — callers fall back
+// to whatever was stored.
+func renderedURLForGist(gistHTMLURL string) string {
+	id := gistIDFromGistHTMLURL(gistHTMLURL)
+	if id == "" {
+		return ""
+	}
+	return gistpreviewURL(id)
+}
+
+// gistpreviewURL builds the gistpreview.github.io URL for a gist id,
+// explicitly pinning the file to share.html.
+func gistpreviewURL(gistID string) string {
+	return "https://gistpreview.github.io/?" + gistID + "/share.html"
+}
+
+// gistIDFromGistHTMLURL extracts the gist ID from a github.com gist URL.
+// Returns "" for anything that doesn't match the expected shape.
+func gistIDFromGistHTMLURL(gistHTMLURL string) string {
+	const prefix = "https://gist.github.com/"
+	if !strings.HasPrefix(gistHTMLURL, prefix) {
+		return ""
+	}
+	rest := strings.Trim(strings.TrimPrefix(gistHTMLURL, prefix), "/")
+	if rest == "" {
+		return ""
+	}
+	// "/" or "" — the ID is always the last segment.
+	parts := strings.Split(rest, "/")
+	return parts[len(parts)-1]
+}
+
+// gistIDFromGithackURL pulls the gist ID out of a stored raw.githack URL
+// (legacy rows that were written before the gistpreview switchover).
+// Returns "" for anything that doesn't match.
+func gistIDFromGithackURL(url string) string {
+	const prefix = "https://gist.githack.com/"
+	if !strings.HasPrefix(url, prefix) {
+		return ""
+	}
+	parts := strings.Split(strings.TrimPrefix(url, prefix), "/")
+	if len(parts) < 2 || parts[1] == "" {
+		return ""
+	}
+	return parts[1]
+}
+
+// Delete removes the gist. A 404 from GitHub is returned to the caller as-is
+// so the service can treat it as "already gone".
+func (b *GistBackend) Delete(ctx context.Context, externalID string) error {
+	if err := b.client.DeleteGist(ctx, externalID); err != nil {
+		var apiErr *github.GitHubAPIError
+		if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound {
+			return err
+		}
+		return fmt.Errorf("delete gist %s: %w", externalID, err)
+	}
+	return nil
+}
+
+func gistDescription(snap *Snapshot) string {
+	if snap == nil || snap.Task.Title == "" {
+		return "kandev task share"
+	}
+	return "kandev share: " + snap.Task.Title
+}
+
+func nonEmpty(s, fallback string) string {
+	if s == "" {
+		return fallback
+	}
+	return s
+}
diff --git a/apps/backend/internal/task/share/gist_backend_test.go b/apps/backend/internal/task/share/gist_backend_test.go
new file mode 100644
index 0000000000..3462906078
--- /dev/null
+++ b/apps/backend/internal/task/share/gist_backend_test.go
@@ -0,0 +1,128 @@
+package share
+
+import (
+	"context"
+	"errors"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/kandev/kandev/internal/github"
+)
+
+func sampleSnapshot() *Snapshot {
+	completed := time.Date(2026, 5, 21, 12, 0, 0, 0, time.UTC)
+	return &Snapshot{
+		Version:    SnapshotVersion,
+		ExportedAt: completed,
+		Task:       TaskMeta{Title: "Fix flaky test"},
+		Session: SessionMeta{
+			AgentType:    "claude-acp",
+			Model:        "claude-opus-4-7",
+			ExecutorType: "local_docker",
+			StartedAt:    completed.Add(-time.Minute),
+			CompletedAt:  &completed,
+		},
+		Messages: []Message{
+			{Role: "user", Ts: completed, Blocks: []Block{{Kind: "text", Text: "hi"}}},
+		},
+		Redaction: RedactionLog{AppliedRules: []string{}},
+	}
+}
+
+func TestGistBackend_Upload_CreatesSecretGistWithExpectedFiles(t *testing.T) {
+	t.Parallel()
+	mock := github.NewMockClient()
+	b := NewGistBackend(mock)
+
+	id, url, err := b.Upload(context.Background(), sampleSnapshot())
+	if err != nil {
+		t.Fatalf("upload: %v", err)
+	}
+	if id == "" || url == "" {
+		t.Fatalf("expected non-empty id/url, got %q / %q", id, url)
+	}
+
+	// The returned URL is the rendered gistpreview.github.io viewer URL,
+	// NOT the raw gist URL — that's what the user shares.
+	if !strings.HasPrefix(url, "https://gistpreview.github.io/?") {
+		t.Fatalf("expected gistpreview.github.io URL, got %q", url)
+	}
+
+	gists := mock.Gists()
+	g, ok := gists[id]
+	if !ok {
+		t.Fatalf("gist %s not stored on mock", id)
+	}
+	if g.Public {
+		t.Fatal("backend should always create secret gists (Public=false)")
+	}
+	for _, name := range []string{"snapshot.json", "README.md", "share.html"} {
+		if _, ok := g.Files[name]; !ok {
+			t.Fatalf("missing %s in gist files", name)
+		}
+	}
+	if !strings.Contains(g.Files["snapshot.json"].Content, `"title": "Fix flaky test"`) {
+		t.Fatalf("snapshot.json does not contain task title: %s", g.Files["snapshot.json"].Content)
+	}
+	if !strings.Contains(g.Files["README.md"].Content, "# Fix flaky test") {
+		t.Fatal("README.md missing task title heading")
+	}
+	if !strings.Contains(g.Files["share.html"].Content, "") {
+		t.Fatal("share.html missing doctype")
+	}
+	if !strings.Contains(g.Files["share.html"].Content, "Fix flaky test") {
+		t.Fatal("share.html missing task title")
+	}
+}
+
+func TestGistBackend_Upload_RejectsOversizedSnapshot(t *testing.T) {
+	t.Parallel()
+	mock := github.NewMockClient()
+	b := NewGistBackend(mock)
+
+	snap := sampleSnapshot()
+	big := strings.Repeat("x", GistMaxBytes+1)
+	snap.Messages = []Message{{Role: "user", Blocks: []Block{{Kind: "text", Text: big}}}}
+
+	_, _, err := b.Upload(context.Background(), snap)
+	if err == nil {
+		t.Fatal("expected ErrSnapshotTooLarge, got nil")
+	}
+	if !errors.Is(err, ErrSnapshotTooLarge) {
+		t.Fatalf("expected ErrSnapshotTooLarge, got %v", err)
+	}
+	if len(mock.Gists()) != 0 {
+		t.Fatalf("no gist should have been created, got %d", len(mock.Gists()))
+	}
+}
+
+func TestGistBackend_Delete_Success(t *testing.T) {
+	t.Parallel()
+	mock := github.NewMockClient()
+	b := NewGistBackend(mock)
+	id, _, err := b.Upload(context.Background(), sampleSnapshot())
+	if err != nil {
+		t.Fatalf("upload: %v", err)
+	}
+	if err := b.Delete(context.Background(), id); err != nil {
+		t.Fatalf("delete: %v", err)
+	}
+	if got := mock.DeletedGists(); len(got) != 1 || got[0] != id {
+		t.Fatalf("expected DeletedGists=[%q], got %v", id, got)
+	}
+}
+
+func TestGistBackend_Delete_PassesThroughNotFoundForCallerInspection(t *testing.T) {
+	t.Parallel()
+	mock := github.NewMockClient()
+	b := NewGistBackend(mock)
+
+	err := b.Delete(context.Background(), "missing-id")
+	if err == nil {
+		t.Fatal("expected error for missing gist")
+	}
+	if !IsAlreadyGone(err) {
+		t.Fatalf("expected IsAlreadyGone to be true, got err=%v", err)
+	}
+}
diff --git a/apps/backend/internal/task/share/gist_css.go b/apps/backend/internal/task/share/gist_css.go
new file mode 100644
index 0000000000..c89e75a8c7
--- /dev/null
+++ b/apps/backend/internal/task/share/gist_css.go
@@ -0,0 +1,386 @@
+package share
+
+// shareCSS is the inlined stylesheet for the rendered share.html page.
+// Self-contained — no @import, no external fonts — so the page renders
+// identically on a fresh CDN with no network outside itself.
+//
+// Color tokens mirror kandev's dark-mode palette
+// (apps/packages/theme/src/globals.css). We use the same oklch() values
+// so the share page reads as part of the product rather than a separate
+// site. oklch() has full support in current Chrome/Safari/Firefox.
+//
+// Layout: classic chat. User bubbles right-aligned with the primary
+// (purple) fill; assistant content left-aligned in the card surface;
+// system messages centered and de-emphasised. Tool calls render inline
+// as collapsed pills inside the assistant bubble. Diffs are full-width
+// code blocks with per-line tinting for adds/dels/hunks.
+const shareCSS = `
+:root {
+  color-scheme: dark;
+
+  /* — Kandev dark-mode tokens (oklch, ported from globals.css) — */
+  --bg: oklch(0.145 0 0);
+  --foreground: oklch(0.985 0 0);
+  --card: oklch(0.205 0 0);
+  --muted: oklch(0.269 0 0);
+  --muted-foreground: oklch(0.708 0 0);
+  --border: oklch(1 0 0 / 10%);
+  --border-strong: oklch(1 0 0 / 18%);
+  --primary: oklch(0.59 0.2 277);
+  --primary-foreground: oklch(0.96 0.02 272);
+  --accent: oklch(0.62 0.18 276);
+  --accent-strong: oklch(0.79 0.1 275);
+  --success: oklch(0.68 0.12 150);
+  --destructive: oklch(0.704 0.191 22.216);
+
+  /* — Derived semantic aliases used in the share layout — */
+  --text: var(--foreground);
+  --text-dim: var(--muted-foreground);
+  --text-faint: oklch(0.55 0 0);
+  --surface: var(--card);
+  --surface-2: oklch(0.235 0 0);
+
+  --diff-add-bg: color-mix(in oklab, var(--success) 14%, transparent);
+  --diff-add-text: oklch(0.85 0.14 150);
+  --diff-del-bg: color-mix(in oklab, var(--destructive) 14%, transparent);
+  --diff-del-text: oklch(0.82 0.16 22);
+  --diff-hunk: var(--accent-strong);
+
+  --radius: 0.375rem;
+  --radius-lg: 0.625rem;
+  --radius-xl: 0.875rem;
+
+  --mono: "Geist Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
+  --sans: "Figtree", "Geist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
+}
+
+* { box-sizing: border-box; }
+
+html, body {
+  margin: 0;
+  padding: 0;
+  background: var(--bg);
+  color: var(--text);
+  font-family: var(--sans);
+  font-size: 15px;
+  line-height: 1.55;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+}
+
+a { color: var(--accent-strong); text-decoration: none; }
+a:hover { text-decoration: underline; }
+code, pre { font-family: var(--mono); }
+::selection { background: var(--primary); color: var(--primary-foreground); }
+
+/* ─── Hero ─────────────────────────────────────────────────────── */
+
+.hero {
+  max-width: 880px;
+  margin: 0 auto;
+  padding: 56px 24px 28px;
+  border-bottom: 1px solid var(--border);
+}
+
+.brand {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  color: var(--text-dim);
+  font-size: 13px;
+  margin-bottom: 16px;
+}
+.brand a { color: var(--text); font-weight: 600; letter-spacing: -0.01em; }
+.brand-sep { color: var(--text-faint); }
+.brand-tag { color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.08em; font-size: 11px; }
+
+.hero h1 {
+  margin: 0 0 18px;
+  font-size: 30px;
+  font-weight: 600;
+  letter-spacing: -0.015em;
+  line-height: 1.2;
+}
+
+.badges { display: flex; flex-wrap: wrap; gap: 6px; }
+.badge {
+  display: inline-flex;
+  align-items: center;
+  padding: 3px 8px;
+  border-radius: var(--radius);
+  background: var(--surface);
+  border: 1px solid var(--border);
+  color: var(--text-dim);
+  font-size: 12px;
+  font-family: var(--mono);
+}
+
+.redaction {
+  margin: 16px 0 0;
+  font-size: 13px;
+  color: var(--text-dim);
+}
+.redaction code {
+  padding: 1px 6px;
+  background: var(--surface);
+  border-radius: 4px;
+  font-size: 12px;
+}
+
+/* ─── Conversation ────────────────────────────────────────────── */
+
+.conv {
+  max-width: 880px;
+  margin: 0 auto;
+  padding: 32px 24px;
+  display: flex;
+  flex-direction: column;
+  gap: 24px;
+}
+
+.group {
+  display: flex;
+  align-items: flex-end;
+  gap: 10px;
+}
+
+.group-user { flex-direction: row-reverse; }
+.group-assistant { flex-direction: row; }
+.group-system { justify-content: center; }
+
+.avatar {
+  flex-shrink: 0;
+  width: 30px;
+  height: 30px;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 16px;
+  background: var(--surface-2);
+  border: 1px solid var(--border-strong);
+  margin-bottom: 4px;
+}
+
+.group-user .avatar {
+  background: var(--primary);
+  border-color: var(--primary);
+}
+.group-system .avatar { display: none; }
+
+.bubble {
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  padding: 12px 16px;
+  border-radius: var(--radius-xl);
+  background: var(--surface);
+  border: 1px solid var(--border);
+}
+.group-assistant .bubble {
+  border-bottom-left-radius: var(--radius);
+  flex: 1;
+}
+.group-user .bubble {
+  background: var(--primary);
+  color: var(--primary-foreground);
+  border-color: var(--primary);
+  border-bottom-right-radius: var(--radius);
+  max-width: 75%;
+}
+.group-system .bubble {
+  background: transparent;
+  border-style: dashed;
+  color: var(--text-dim);
+  font-size: 13px;
+  max-width: 480px;
+  text-align: center;
+}
+
+/* Role label sits above the content. Hidden inside user bubbles since
+   the right-alignment + color already convey "you said this." */
+.role {
+  font-size: 10.5px;
+  font-weight: 600;
+  text-transform: uppercase;
+  letter-spacing: 0.08em;
+  color: var(--text-faint);
+  margin: -2px 0 -4px;
+}
+.group-user .role { display: none; }
+.group-assistant .role { color: var(--accent-strong); }
+
+/* ─── Text inside bubbles ─────────────────────────────────────── */
+
+.text > * { margin: 0; }
+.text > * + * { margin-top: 8px; }
+.text p { line-height: 1.6; }
+.group-user .text { font-size: 15px; }
+
+code.inline {
+  background: color-mix(in oklab, var(--foreground) 8%, transparent);
+  padding: 1px 5px;
+  border-radius: 4px;
+  font-size: 0.9em;
+}
+.group-user code.inline {
+  background: color-mix(in oklab, white 22%, transparent);
+  color: var(--primary-foreground);
+}
+
+pre.code {
+  margin: 4px 0;
+  padding: 12px 14px;
+  border-radius: var(--radius-lg);
+  background: var(--bg);
+  border: 1px solid var(--border);
+  overflow-x: auto;
+  font-size: 12.5px;
+  line-height: 1.5;
+  color: var(--text);
+}
+.group-user pre.code {
+  background: color-mix(in oklab, black 25%, transparent);
+  border-color: color-mix(in oklab, white 14%, transparent);
+  color: var(--primary-foreground);
+}
+
+/* ─── Tool call / result pills ────────────────────────────────── */
+
+.tool {
+  border: 1px solid var(--border-strong);
+  border-radius: var(--radius-lg);
+  background: color-mix(in oklab, var(--foreground) 3%, transparent);
+  overflow: hidden;
+}
+.tool > summary {
+  list-style: none;
+  cursor: pointer;
+  padding: 7px 12px;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 13px;
+  color: var(--text-dim);
+  user-select: none;
+}
+.tool > summary::-webkit-details-marker { display: none; }
+.tool > summary:hover { background: color-mix(in oklab, var(--foreground) 4%, transparent); }
+
+.tool-icon { font-size: 13px; }
+.tool-name {
+  color: var(--text);
+  font-family: var(--mono);
+  font-size: 12px;
+}
+.tool-summary {
+  flex: 1;
+  color: var(--text-faint);
+  font-family: var(--mono);
+  font-size: 12px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  min-width: 0;
+}
+.tool-chev {
+  color: var(--text-faint);
+  font-size: 10px;
+  transition: transform 0.15s ease;
+}
+.tool[open] > summary .tool-chev { transform: rotate(90deg); }
+
+.args, .output {
+  margin: 0;
+  padding: 12px 14px;
+  background: var(--bg);
+  border-top: 1px solid var(--border);
+  font-size: 12.5px;
+  line-height: 1.5;
+  overflow-x: auto;
+  white-space: pre;
+  color: var(--text);
+}
+
+/* ─── Diff blocks ─────────────────────────────────────────────── */
+
+.diff {
+  border: 1px solid var(--border-strong);
+  border-radius: var(--radius-lg);
+  background: var(--bg);
+  overflow: hidden;
+}
+.diff-head {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 8px 12px;
+  border-bottom: 1px solid var(--border);
+  font-size: 13px;
+  color: var(--text-dim);
+  background: var(--surface-2);
+}
+.diff-head code { color: var(--text); font-size: 12px; }
+.diff-body {
+  margin: 0;
+  padding: 8px 0;
+  font-size: 12.5px;
+  line-height: 1.5;
+  overflow-x: auto;
+}
+.diff-body > span {
+  display: block;
+  padding: 0 14px;
+  white-space: pre;
+}
+.diff-add { background: var(--diff-add-bg); color: var(--diff-add-text); }
+.diff-del { background: var(--diff-del-bg); color: var(--diff-del-text); }
+.diff-hunk { color: var(--diff-hunk); }
+.diff-file { color: var(--text-faint); }
+
+/* ─── Misc ────────────────────────────────────────────────────── */
+
+.empty {
+  text-align: center;
+  color: var(--text-faint);
+  padding: 48px 0;
+}
+
+.page-footer {
+  max-width: 880px;
+  margin: 0 auto;
+  padding: 32px 24px 48px;
+  border-top: 1px solid var(--border);
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 13px;
+  color: var(--text-faint);
+}
+.cta {
+  display: inline-flex;
+  align-items: center;
+  padding: 6px 12px;
+  border-radius: var(--radius);
+  background: var(--primary);
+  color: var(--primary-foreground) !important;
+  font-weight: 500;
+}
+.cta:hover { text-decoration: none; background: var(--accent); }
+.foot-sep { color: var(--text-faint); }
+.foot-link { color: var(--text-dim); }
+.foot-version { color: var(--text-faint); font-family: var(--mono); font-size: 12px; }
+
+/* ─── Mobile ──────────────────────────────────────────────────── */
+
+@media (max-width: 640px) {
+  .hero { padding: 32px 16px 20px; }
+  .hero h1 { font-size: 22px; }
+  .conv { padding: 20px 12px; gap: 18px; }
+  .group { gap: 8px; }
+  .avatar { width: 26px; height: 26px; font-size: 14px; }
+  .bubble { padding: 10px 12px; border-radius: var(--radius-lg); }
+  .group-user .bubble { max-width: 85%; }
+}
+`
diff --git a/apps/backend/internal/task/share/gist_html.go b/apps/backend/internal/task/share/gist_html.go
new file mode 100644
index 0000000000..69ca33ba14
--- /dev/null
+++ b/apps/backend/internal/task/share/gist_html.go
@@ -0,0 +1,241 @@
+package share
+
+import (
+	"fmt"
+	"html"
+	"strings"
+)
+
+// kandevRepoURL is the public GitHub repo for the project — used in the
+// share page's "Try kandev" CTA and brand link. Pointing at the repo
+// instead of a marketing site signals "this is open source, here's the
+// code" which is the actual value proposition for the audience that
+// receives a shared task.
+const kandevRepoURL = "https://github.com/kdlbs/kandev"
+
+// BuildShareHTML produces a self-contained styled HTML page that renders
+// the snapshot as a real chat conversation: user bubbles right-aligned,
+// assistant content left-aligned, consecutive same-role messages fused
+// into one block, and tool calls collapsed inline rather than rendered
+// as their own messages.
+func BuildShareHTML(snap *Snapshot) string {
+	if snap == nil {
+		return "kandev share"
+	}
+	var b strings.Builder
+	b.WriteString("\n\n")
+	writeHTMLHead(&b, snap)
+	b.WriteString("\n")
+	// Inline the stylesheet at the top of  rather than in  so it
+	// survives renderers (notably gistpreview.github.io) that copy only the
+	// body content into their own page — dropping  and any \n")
+	writeHTMLHero(&b, snap)
+	b.WriteString("
\n") + writeHTMLConversation(&b, snap.Messages) + b.WriteString("
\n") + writeHTMLFooter(&b, snap) + b.WriteString("\n\n") + return b.String() +} + +func writeHTMLHead(b *strings.Builder, snap *Snapshot) { + title := html.EscapeString(nonEmpty(snap.Task.Title, "Untitled task")) + fmt.Fprintf(b, "\n\n") + fmt.Fprintf(b, "\n") + fmt.Fprintf(b, "%s — kandev share\n", title) + fmt.Fprintf(b, "\n", title) + b.WriteString("\n\n") +} + +func writeHTMLHero(b *strings.Builder, snap *Snapshot) { + title := html.EscapeString(nonEmpty(snap.Task.Title, "Untitled task")) + b.WriteString("
\n") + b.WriteString("
kandev") + b.WriteString("·shared task
\n") + fmt.Fprintf(b, "

%s

\n", title) + b.WriteString("
") + writeHTMLBadge(b, snap.Session.AgentType) + writeHTMLBadge(b, snap.Session.Model) + writeHTMLBadge(b, snap.Session.ExecutorType) + writeHTMLBadge(b, fmt.Sprintf("%d messages", len(snap.Messages))) + if completed := formatPtrTime(snap.Session.CompletedAt); completed != "" { + writeHTMLBadge(b, completed) + } + b.WriteString("
\n") + if len(snap.Redaction.AppliedRules) > 0 { + fmt.Fprintf(b, "

🛡️ Redacted: %s

\n", + html.EscapeString(strings.Join(snap.Redaction.AppliedRules, ", "))) + } + b.WriteString("
\n") +} + +func writeHTMLBadge(b *strings.Builder, text string) { + if text == "" { + return + } + fmt.Fprintf(b, "%s", html.EscapeString(text)) +} + +// messageGroup is a run of consecutive messages sharing the same role. +// Blocks from every member are flattened in order so the whole run renders +// inside a single bubble. +type messageGroup struct { + role string + blocks []Block +} + +// groupMessages collapses consecutive same-role messages so the renderer +// can draw one bubble per group. Empty messages (no blocks) are dropped +// rather than producing empty groups. +func groupMessages(messages []Message) []messageGroup { + var groups []messageGroup + for _, m := range messages { + if len(m.Blocks) == 0 { + continue + } + if n := len(groups); n > 0 && groups[n-1].role == m.Role { + groups[n-1].blocks = append(groups[n-1].blocks, m.Blocks...) + continue + } + groups = append(groups, messageGroup{ + role: m.Role, + blocks: append([]Block(nil), m.Blocks...), + }) + } + return groups +} + +func writeHTMLConversation(b *strings.Builder, messages []Message) { + groups := groupMessages(messages) + if len(groups) == 0 { + b.WriteString("

No messages.

\n") + return + } + for _, g := range groups { + writeHTMLGroup(b, g) + } +} + +func writeHTMLGroup(b *strings.Builder, g messageGroup) { + cls, label, icon := messageRoleAttrs(g.role) + fmt.Fprintf(b, "
\n", cls) + fmt.Fprintf(b, "
%s
\n", icon) + b.WriteString("
\n") + fmt.Fprintf(b, "
%s
\n", html.EscapeString(label)) + for _, block := range g.blocks { + writeHTMLBlock(b, block) + } + b.WriteString("
\n
\n") +} + +func messageRoleAttrs(role string) (cls, label, icon string) { + switch role { + case roleUser: + return "user", "You", "🧑" + case roleAssistant: + return "assistant", "Assistant", "🤖" + case roleSystem: + return "system", "System", "⚙️" + } + return "other", role, "•" +} + +func writeHTMLBlock(b *strings.Builder, block Block) { + switch block.Kind { + case blockKindText: + writeHTMLText(b, block.Text) + case blockKindToolCall: + writeHTMLToolCall(b, block) + case blockKindToolResult: + writeHTMLToolResult(b, block) + case blockKindDiff: + writeHTMLDiff(b, block) + } +} + +func writeHTMLToolCall(b *strings.Builder, block Block) { + name := html.EscapeString(nonEmpty(block.ToolName, "tool")) + summary := html.EscapeString(strings.TrimSpace(block.Text)) + // Closed by default — tool calls are noise unless the reader cares. + b.WriteString("
\n") + b.WriteString("🔧") + fmt.Fprintf(b, "%s", name) + if summary != "" { + fmt.Fprintf(b, "%s", summary) + } + b.WriteString("▸") + b.WriteString("\n") + if len(block.Args) > 0 { + fmt.Fprintf(b, "
%s
\n", + html.EscapeString(prettyJSON(block.Args))) + } + b.WriteString("
\n") +} + +func writeHTMLToolResult(b *strings.Builder, block Block) { + out := strings.TrimRight(block.Output, "\n") + if strings.TrimSpace(out) == "" { + return + } + label := "Tool output" + if block.Truncated { + label += " (truncated)" + } + b.WriteString("
\n") + b.WriteString("📤") + fmt.Fprintf(b, "%s", html.EscapeString(label)) + b.WriteString("▸") + b.WriteString("\n") + fmt.Fprintf(b, "
%s
\n", html.EscapeString(out)) + b.WriteString("
\n") +} + +func writeHTMLDiff(b *strings.Builder, block Block) { + if strings.TrimSpace(block.UnifiedDiff) == "" { + return + } + path := html.EscapeString(nonEmpty(block.Path, "diff")) + fmt.Fprintf(b, "
\n
📝") + fmt.Fprintf(b, "%s
\n
", path)
+	for _, line := range strings.Split(strings.TrimRight(block.UnifiedDiff, "\n"), "\n") {
+		writeHTMLDiffLine(b, line)
+	}
+	b.WriteString("
\n
\n") +} + +func writeHTMLDiffLine(b *strings.Builder, line string) { + cls := "diff-ctx" + switch { + case strings.HasPrefix(line, "+++") || strings.HasPrefix(line, "---"): + cls = "diff-file" + case strings.HasPrefix(line, "@@"): + cls = "diff-hunk" + case strings.HasPrefix(line, "+"): + cls = "diff-add" + case strings.HasPrefix(line, "-"): + cls = "diff-del" + } + fmt.Fprintf(b, "%s\n", cls, html.EscapeString(line)) +} + +func writeHTMLFooter(b *strings.Builder, snap *Snapshot) { + b.WriteString("
\n") + b.WriteString("Try kandev on GitHub →\n") + b.WriteString("·\n") + b.WriteString("snapshot.json\n") + if snap.KandevVersion != "" { + fmt.Fprintf(b, "·\nkandev %s\n", + html.EscapeString(snap.KandevVersion)) + } + b.WriteString("
\n") +} diff --git a/apps/backend/internal/task/share/gist_html_test.go b/apps/backend/internal/task/share/gist_html_test.go new file mode 100644 index 0000000000..519195af29 --- /dev/null +++ b/apps/backend/internal/task/share/gist_html_test.go @@ -0,0 +1,220 @@ +package share + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +func TestBuildShareHTML_RendersChatLayout(t *testing.T) { + t.Parallel() + completed := time.Date(2026, 5, 21, 12, 0, 0, 0, time.UTC) + snap := &Snapshot{ + Version: SnapshotVersion, + ExportedAt: completed, + Task: TaskMeta{Title: "Investigate test"}, + Session: SessionMeta{ + AgentType: "claude-acp", + Model: "claude-opus-4-7", + ExecutorType: "local_docker", + StartedAt: completed.Add(-time.Minute), + CompletedAt: &completed, + }, + Messages: []Message{ + {Role: roleUser, Ts: completed, Blocks: []Block{{Kind: blockKindText, Text: "why is X flaky?"}}}, + {Role: roleAssistant, Ts: completed, Blocks: []Block{{Kind: blockKindText, Text: "Looking into it."}}}, + {Role: roleAssistant, Ts: completed, Blocks: []Block{ + {Kind: blockKindToolCall, ToolName: "shell", Text: "ran tests", + Args: json.RawMessage(`{"cmd":"go test ./..."}`)}, + }}, + {Role: roleAssistant, Ts: completed, Blocks: []Block{ + {Kind: blockKindToolResult, Output: "FAIL pkg/foo TestX"}, + {Kind: blockKindText, Text: "Found it. Use `t.Cleanup`."}, + {Kind: blockKindDiff, Path: "src/x.go", + UnifiedDiff: "--- a/x.go\n+++ b/x.go\n@@ -1,1 +1,1 @@\n-old\n+new\n context\n"}, + }}, + }, + Redaction: RedactionLog{AppliedRules: []string{RuleAbsPath}}, + } + + doc := BuildShareHTML(snap) + + assertContains(t, doc, "") + assertContains(t, doc, "Investigate <flaky> test — kandev share") + assertContains(t, doc, "