diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0f1f93b..89acb35 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -450,17 +450,17 @@ makes them a natural addition. **Surfaces + Editor** - [x] ~~Extension loader: surfaces from manifest.json~~ (v0.27.0 — `/s/:slug` route) -- [x] ~~Editor drag-drop file reorder~~ (v0.27.0 — workspace debt) +- [ ] Editor drag-drop file reorder (was v0.21.5 — deferred, no DnD infra in file-tree.js) - [ ] Article drag-to-reorder outline sections (was v0.21.6) - [ ] Article-specific AI tools: suggest_outline, expand_section, check_citations (was v0.21.6) - [x] ~~PDF export via pandoc~~ (shipped v0.22.4) - [ ] Mobile: mode selector collapses to hamburger/bottom nav (was v0.21.3) -- [x] ~~Pane state persistence per-user/per-project~~ (v0.27.0 — workspace debt) +- [x] ~~Pane state persistence per-user/per-project~~ (v0.27.0 — debounced sync to user_settings) **Workspace + Git** -- [x] ~~Workspace settings UI: git config section in channel/project settings~~ (v0.27.0 — workspace debt) +- [ ] Workspace settings UI: git config section (was v0.21.4 — deferred, needs vault-encrypted git credentials) - [ ] User settings: git credentials management UI (was v0.21.4 — needs vault extension, v0.28.0+) -- [x] ~~`.gitignore` respect in workspace indexing~~ (v0.27.0 — workspace debt) +- [x] ~~`.gitignore` respect in workspace indexing~~ (v0.27.0 — Reconcile walk filter) - [ ] Integration tests: clone, commit, push/pull cycle — requires git binary in CI (was v0.21.4) **Provider Health + Routing** @@ -762,7 +762,7 @@ See [DESIGN-0.25.0.md](DESIGN-0.25.0.md) for full spec. - [x] Core surfaces (chat, admin, settings, editor, notes) on manifest pattern - [x] `window.__PAGE_DATA__` injection from declared `data_requires` - [x] `surface_registry` table — admin can enable/disable surfaces (Migration 022) -- [ ] `/s/:slug` route namespace for extension/dynamic surfaces — v0.27.0 +- [x] `/s/:slug` route namespace for extension/dynamic surfaces (shipped v0.27.0) **Component Extraction** ✅ - [x] FileTree: extracted from `editor-mode.js`, standalone component with Go template partial + JS hydration @@ -815,7 +815,7 @@ See [DESIGN-0.25.0.md](DESIGN-0.25.0.md) for full spec. **Shared Surface Routes** — partially shipped - [x] `/admin/:section`, `/settings/:section` — full-page surface routes -- [ ] `/s/editor/:wsId`, `/s/article/:wsId/:path` — v0.27.0 (extension surface routes) +- [ ] `/s/editor/:wsId`, `/s/article/:wsId/:path` — TBD (editor uses `/editor/:wsId` today) - [ ] `/p/:id` — shared project view — TBD **Shipped in v0.26.0:** @@ -862,18 +862,18 @@ release notes. - [x] Route registration test (`TestRouteRegistration`) - [x] Workflow CRUD test (`TestWorkflowCRUD`, `TestWorkflowValidation`) -**Moved to v0.27.0 Phase 2 (Workflow Polish):** -- [ ] Team-scoped workflow management UI (team admin settings surface) -- [ ] Drag-and-drop stage reorder in builder (backend supports it, UI is manual) -- [ ] `on_complete` workflow chaining (column exists nullable, not wired) -- [ ] Workflow retention enforcement (column exists, not enforced) -- [ ] Stage persona auto-switch in chat UI -- [ ] Round-robin auto-assignment -- [ ] Assignment notifications via WebSocket -- [ ] Channel header stage indicator + advance/reject controls +**Shipped in v0.27.0 Phase 2 (Workflow Polish):** +- [x] Team-scoped workflow management UI (Settings → Workflows section) +- [x] Drag-and-drop stage reorder in builder (`_wfWireStageDnD()`) +- [x] `on_complete` workflow chaining (`triggerOnComplete()` in workflow_instances.go) +- [x] Workflow retention enforcement (staleness sweep extended) +- [x] Stage persona auto-switch in chat UI (via `workflow.advanced` WS event) +- [x] Round-robin auto-assignment (`tryRoundRobin()` in workflow_instances.go) +- [x] Assignment notifications via WebSocket (`workflow.assigned`, `workflow.claimed`) +- [x] Channel header stage indicator + advance/reject controls -**Moved to v0.27.0 Phase 1:** -- [ ] Extension surface routes (`/s/:slug` namespace) +**Shipped in v0.27.0 Phase 1:** +- [x] Extension surface routes (`/s/:slug` namespace — `RenderExtensionSurface()`) **Shipped (verified in code audit):** - [x] Persona tool grant enforcement in completion handler (`completion.go:928`) @@ -887,31 +887,33 @@ Depends on: workflow engine (v0.26.0), dynamic surfaces (v0.25.0). See [DESIGN-0.27.0.md](DESIGN-0.27.0.md) for full spec. -**Phase 1: Extension Surface Routes (`/s/:slug`)** -- [ ] `surface-extension` Go template (HTML + CSS + scripts blocks) -- [ ] `base.html` conditional chain extended with `{{if .Manifest}}` fallthrough -- [ ] `loadExtensionSurfaces()` in page engine (reads extension manifests from DB at startup) -- [ ] nginx location block for `/surfaces/` static assets -- [ ] Frontend nav renders extension surfaces from `ListEnabledSurfaces` -- [ ] Admin surfaces section: upload/enable/disable/uninstall (API already exists) -- [ ] Sample `.surface` archive (hello-world dashboard) for testing -- [ ] CSP nonce propagation for extension scripts +**Phase 1: Extension Surface Routes (`/s/:slug`)** ✅ +- [x] `surface-extension` Go template (HTML + CSS + scripts blocks) +- [x] `base.html` conditional chain extended with `{{if .Manifest}}` fallthrough +- [x] `RenderExtensionSurface()` catch-all handler — runtime DB lookup, no restart needed +- [x] nginx location block for `/surfaces/` static assets (unified + split deployment) +- [x] Frontend nav renders extension surfaces from DB query at render time +- [x] Admin surfaces section: upload/enable/disable/uninstall (API + UI wired) +- [x] Sample `.surface` archive (`hello-dashboard`) for testing +- [x] CSP nonce propagation for extension scripts +- [x] `EXTENSION-SURFACES.md` authoring guide (manifest, API, CSS properties, admin API) +- [x] `EXTENSIONS.md` §6 rewritten to reference new authoring guide -**Phase 2: Workflow Engine Polish (v0.26.0 debt)** -- [ ] Channel header workflow stage indicator + advance/reject controls -- [ ] Stage persona auto-switch in chat UI -- [ ] Assignment notifications via WebSocket (`workflow.assigned`, `workflow.claimed`) -- [ ] Round-robin auto-assignment (per-stage `transition_rules.auto_assign`) -- [ ] `on_complete` workflow chaining (column exists, wire trigger logic) -- [ ] Workflow retention enforcement (extend staleness sweep goroutine) -- [ ] Drag-and-drop stage reorder in workflow builder UI -- [ ] Team-scoped workflow management UI (team settings → Workflows section) +**Phase 2: Workflow Engine Polish (v0.26.0 debt)** ✅ +- [x] Channel header workflow stage indicator + advance/reject controls +- [x] Stage persona auto-switch in chat UI (via `workflow.advanced` WS event → stage bar refresh) +- [x] Assignment notifications via WebSocket (`workflow.assigned`, `workflow.claimed`) +- [x] Round-robin auto-assignment (per-stage `transition_rules.auto_assign`) +- [x] `on_complete` workflow chaining (`triggerOnComplete()` — slug lookup, data mapping, channel creation) +- [x] Workflow retention enforcement (staleness sweep extended — archive/delete by policy) +- [x] Drag-and-drop stage reorder in workflow builder UI (`_wfWireStageDnD()`) +- [x] Team-scoped workflow management UI (Settings → Workflows section) -**Phase 3: Workspace + Editor Debt** -- [ ] `.gitignore` respect in workspace indexing (`workspace/indexer.go`) -- [ ] Workspace settings UI: git config section in channel/project settings -- [ ] Pane state persistence per-user/per-project (save/restore in `user_settings`) -- [ ] Editor drag-drop file reorder (workspace file tree DnD) +**Phase 3: Workspace + Editor Debt** (partial) +- [x] `.gitignore` respect in workspace indexing (`workspace/fs.go` — Reconcile walk, `loadGitignore`, `matchesGitignore`) +- [x] Pane state persistence per-user/per-project (debounced sync to `user_settings` API, cross-device restore) +- [ ] Workspace settings UI: git config section — deferred (broader scope, needs vault-encrypted git credentials) +- [ ] Editor drag-drop file reorder — deferred (greenfield, no existing DnD in file-tree.js) --- diff --git a/server/events/types.go b/server/events/types.go index 2c684c6..74cd455 100644 --- a/server/events/types.go +++ b/server/events/types.go @@ -66,6 +66,12 @@ var routeTable = map[string]Direction{ // Workspace (v0.21.5) "workspace.file.": DirToClient, // file changed events for live editor updates + // Workflow (v0.27.0) + "workflow.assigned": DirToClient, // new assignment → team members + "workflow.claimed": DirToClient, // assignment claimed → team + claimer + "workflow.advanced": DirToClient, // stage advanced → channel participants + "workflow.completed": DirToClient, // workflow finished → channel participants + // Plugin hooks — never cross the wire "plugin.hook.": DirLocal, "internal.": DirLocal, diff --git a/server/handlers/route_test.go b/server/handlers/route_test.go index 5f19f73..6e12802 100644 --- a/server/handlers/route_test.go +++ b/server/handlers/route_test.go @@ -85,7 +85,7 @@ func TestRouteRegistration(t *testing.T) { protected.GET("/workflows/:id/versions/:version", wfH.GetVersion) // Workflow instances - wfInstH := NewWorkflowInstanceHandler(stores) + wfInstH := NewWorkflowInstanceHandler(stores, nil, nil) protected.POST("/workflows/:id/start", wfInstH.Start) protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus) protected.POST("/channels/:id/workflow/advance", wfInstH.Advance) diff --git a/server/handlers/workflow_instances.go b/server/handlers/workflow_instances.go index 44057f4..2abc0c9 100644 --- a/server/handlers/workflow_instances.go +++ b/server/handlers/workflow_instances.go @@ -10,7 +10,9 @@ import ( "github.com/gin-gonic/gin" "git.gobha.me/xcaliber/chat-switchboard/database" + "git.gobha.me/xcaliber/chat-switchboard/events" "git.gobha.me/xcaliber/chat-switchboard/models" + "git.gobha.me/xcaliber/chat-switchboard/notifications" "git.gobha.me/xcaliber/chat-switchboard/store" "git.gobha.me/xcaliber/chat-switchboard/tools" ) @@ -20,11 +22,13 @@ import ( // advancing/rejecting stages, and querying status. type WorkflowInstanceHandler struct { - stores store.Stores + stores store.Stores + hub *events.Hub + notifSvc *notifications.Service } -func NewWorkflowInstanceHandler(stores store.Stores) *WorkflowInstanceHandler { - return &WorkflowInstanceHandler{stores: stores} +func NewWorkflowInstanceHandler(stores store.Stores, hub *events.Hub, notifSvc *notifications.Service) *WorkflowInstanceHandler { + return &WorkflowInstanceHandler{stores: stores, hub: hub, notifSvc: notifSvc} } // ── Start ─────────────────────────────────── @@ -198,6 +202,15 @@ func (h *WorkflowInstanceHandler) Advance(c *gin.Context) { return } tools.CreateWorkflowStageNote(ctx, h.stores, channelID, currentStage, body.Data, "") + + // v0.27.0: Emit workflow.completed WS event + h.emitWorkflowEvent("workflow.completed", channelID, map[string]any{ + "channel_id": channelID, "workflow_id": workflowID, "stage": nextStage, + }) + + // v0.27.0: on_complete chaining — trigger target workflow if configured + h.triggerOnComplete(ctx, workflowID, channelID, mergedData) + c.JSON(http.StatusOK, gin.H{"status": "completed", "current_stage": nextStage}) return } @@ -230,13 +243,23 @@ func (h *WorkflowInstanceHandler) Advance(c *gin.Context) { } } - // Notify assignment team (stub — fully wired in v0.26.4) + // v0.27.0: Assignment + round-robin + WS notifications if nextStageDef.AssignmentTeamID != nil { - tools.CreateWorkflowAssignment(ctx, channelID, nextStage, *nextStageDef.AssignmentTeamID) - log.Printf("Workflow stage '%s' assigned to team %s (channel %s)", - nextStageDef.Name, *nextStageDef.AssignmentTeamID, channelID) + assignmentID := tools.CreateWorkflowAssignment(ctx, channelID, nextStage, *nextStageDef.AssignmentTeamID) + + // Round-robin auto-assignment if configured + assignedTo := h.tryRoundRobin(ctx, nextStageDef, assignmentID) + + // Notify team members about new assignment + h.notifyAssignment(ctx, *nextStageDef.AssignmentTeamID, channelID, nextStageDef.Name, assignedTo) } + // v0.27.0: Emit workflow.advanced WS event + h.emitWorkflowEvent("workflow.advanced", channelID, map[string]any{ + "channel_id": channelID, "workflow_id": workflowID, + "stage": nextStage, "stage_name": nextStageDef.Name, + }) + c.JSON(http.StatusOK, gin.H{ "status": "active", "current_stage": nextStage, @@ -315,5 +338,236 @@ func (h *WorkflowInstanceHandler) readWorkflowState(ctx context.Context, channel return } +// emitWorkflowEvent pushes a workflow event to all connected clients in the channel's room. +func (h *WorkflowInstanceHandler) emitWorkflowEvent(label, channelID string, data map[string]any) { + if h.hub == nil { + return + } + payload, _ := json.Marshal(data) + h.hub.GetBus().Publish(events.Event{ + Label: label, + Room: channelID, + Payload: payload, + Ts: time.Now().UnixMilli(), + }) +} + +// triggerOnComplete checks if the workflow has an on_complete chain config +// and starts the target workflow if so. +func (h *WorkflowInstanceHandler) triggerOnComplete(ctx context.Context, workflowID, channelID, mergedData string) { + if h.stores.Workflows == nil { + return + } + wf, err := h.stores.Workflows.GetByID(ctx, workflowID) + if err != nil || wf == nil || len(wf.OnComplete) == 0 || string(wf.OnComplete) == "null" { + return + } + + var chain struct { + Action string `json:"action"` + TargetSlug string `json:"target_slug"` + DataMap map[string]string `json:"data_map"` // source_key → target_key + } + if err := json.Unmarshal(wf.OnComplete, &chain); err != nil || chain.Action != "start_workflow" || chain.TargetSlug == "" { + return + } + + // Look up target workflow + target, err := h.stores.Workflows.GetBySlug(ctx, wf.TeamID, chain.TargetSlug) + if err != nil || target == nil || !target.IsActive { + log.Printf("[workflow] on_complete: target workflow '%s' not found or inactive", chain.TargetSlug) + return + } + + // Map stage_data from completed workflow to initial stage_data for target + var sourceData map[string]any + if err := json.Unmarshal([]byte(mergedData), &sourceData); err != nil { + sourceData = map[string]any{} + } + targetData := map[string]any{} + if len(chain.DataMap) > 0 { + for srcKey, tgtKey := range chain.DataMap { + if v, ok := sourceData[srcKey]; ok { + targetData[tgtKey] = v + } + } + } else { + // No mapping = pass all data through + targetData = sourceData + } + + // Get the channel owner to start the chained workflow as + var ownerID string + _ = database.DB.QueryRowContext(ctx, database.Q(` + SELECT user_id FROM channels WHERE id = $1 + `), channelID).Scan(&ownerID) + if ownerID == "" { + return + } + + // Create a new workflow channel for the target + ver, err := h.stores.Workflows.GetLatestVersion(ctx, target.ID) + if err != nil { + log.Printf("[workflow] on_complete: target '%s' has no published version", chain.TargetSlug) + return + } + stages, err := h.stores.Workflows.ListStages(ctx, target.ID) + if err != nil || len(stages) == 0 { + return + } + + ch := &models.Channel{ + UserID: ownerID, + Title: target.Name + " (chained)", + Description: target.Description, + Type: "workflow", + TeamID: target.TeamID, + } + if err := h.stores.Channels.Create(ctx, ch); err != nil { + log.Printf("[workflow] on_complete: failed to create chained channel: %v", err) + return + } + + initialData, _ := json.Marshal(targetData) + _, err = database.DB.ExecContext(ctx, database.Q(` + UPDATE channels + SET workflow_id = $1, workflow_version = $2, current_stage = 0, + stage_data = $3, workflow_status = 'active', + last_activity_at = $4, ai_mode = 'auto' + WHERE id = $5 + `), target.ID, ver.VersionNumber, string(initialData), time.Now().UTC(), ch.ID) + if err != nil { + log.Printf("[workflow] on_complete: failed to set workflow columns: %v", err) + return + } + + _ = h.stores.Channels.AddParticipant(ctx, &models.ChannelParticipant{ + ChannelID: ch.ID, ParticipantType: "user", ParticipantID: ownerID, Role: "owner", + }) + if stages[0].PersonaID != nil { + _ = h.stores.Channels.AddParticipant(ctx, &models.ChannelParticipant{ + ChannelID: ch.ID, ParticipantType: "persona", ParticipantID: *stages[0].PersonaID, Role: "member", + }) + } + + log.Printf("[workflow] on_complete: chained '%s' → '%s' (channel %s → %s)", + wf.Slug, target.Slug, channelID, ch.ID) +} + +// tryRoundRobin checks if the stage has auto_assign:"round_robin" in +// transition_rules and assigns the newly created assignment to the +// least-recently-assigned team member. Returns the assigned user ID or "". +func (h *WorkflowInstanceHandler) tryRoundRobin(ctx context.Context, stage models.WorkflowStage, assignmentID string) string { + if assignmentID == "" || stage.AssignmentTeamID == nil { + return "" + } + + // Check transition_rules for auto_assign + var rules struct { + AutoAssign string `json:"auto_assign"` + } + if len(stage.TransitionRules) > 0 { + _ = json.Unmarshal(stage.TransitionRules, &rules) + } + if rules.AutoAssign != "round_robin" { + return "" + } + + // Get team members + members, err := h.stores.Teams.ListMembers(ctx, *stage.AssignmentTeamID) + if err != nil || len(members) == 0 { + return "" + } + + // Find the least-recently-assigned member. + // Query: for each member, find their most recent claimed_at in workflow_assignments. + // Pick the member with the oldest (or null) claimed_at. + var bestUserID string + bestUserID = members[0].UserID // fallback to first member + + rows, err := database.DB.QueryContext(ctx, database.Q(` + SELECT m.user_id, MAX(wa.claimed_at) as last_claim + FROM team_members m + LEFT JOIN workflow_assignments wa ON wa.assigned_to = m.user_id AND wa.team_id = $1 + WHERE m.team_id = $2 + GROUP BY m.user_id + ORDER BY last_claim ASC NULLS FIRST + LIMIT 1 + `), *stage.AssignmentTeamID, *stage.AssignmentTeamID) + if err == nil { + defer rows.Close() + if rows.Next() { + var uid string + var lastClaim *time.Time + if err := rows.Scan(&uid, &lastClaim); err == nil { + bestUserID = uid + } + } + } + + // Claim the assignment for this user + now := time.Now().UTC() + _, err = database.DB.ExecContext(ctx, database.Q(` + UPDATE workflow_assignments + SET assigned_to = $1, status = 'claimed', claimed_at = $2 + WHERE id = $3 AND status = 'unassigned' + `), bestUserID, now, assignmentID) + if err != nil { + log.Printf("[workflow] round-robin: failed to auto-assign %s to %s: %v", assignmentID, bestUserID, err) + return "" + } + + log.Printf("[workflow] round-robin: auto-assigned %s to user %s", assignmentID, bestUserID) + return bestUserID +} + +// notifyAssignment sends notifications to team members about a new workflow assignment. +func (h *WorkflowInstanceHandler) notifyAssignment(ctx context.Context, teamID, channelID, stageName, assignedTo string) { + if h.notifSvc == nil || h.stores.Teams == nil { + return + } + + members, err := h.stores.Teams.ListMembers(ctx, teamID) + if err != nil { + return + } + + for _, m := range members { + title := "New workflow assignment" + body := "Stage '" + stageName + "' needs review" + if assignedTo != "" && m.UserID == assignedTo { + title = "Workflow assigned to you" + body = "Stage '" + stageName + "' has been assigned to you (round-robin)" + } + + n := &models.Notification{ + UserID: m.UserID, + Type: "workflow.assigned", + Title: title, + Body: body, + ResourceType: models.ResourceTypeChannel, + ResourceID: channelID, + } + if err := h.notifSvc.Notify(ctx, n); err != nil { + log.Printf("[workflow] notify assignment: %v", err) + } + } + + // Also emit targeted WS event for immediate UI update + if h.hub != nil { + payload, _ := json.Marshal(map[string]any{ + "channel_id": channelID, "team_id": teamID, + "stage_name": stageName, "assigned_to": assignedTo, + }) + for _, m := range members { + h.hub.SendToUser(m.UserID, events.Event{ + Label: "workflow.assigned", + Payload: payload, + Ts: time.Now().UnixMilli(), + }) + } + } +} + // createStageNote and mergeStageData are now in tools/workflow.go // (shared between handler and workflow_advance tool). diff --git a/server/handlers/workflow_test.go b/server/handlers/workflow_test.go index a001f42..1453e3b 100644 --- a/server/handlers/workflow_test.go +++ b/server/handlers/workflow_test.go @@ -311,7 +311,7 @@ func setupWorkflowHarness(t *testing.T) *workflowHarness { protected.GET("/workflows/:id/versions/:version", wfH.GetVersion) // Workflow instances - wfInstH := NewWorkflowInstanceHandler(stores) + wfInstH := NewWorkflowInstanceHandler(stores, nil, nil) protected.POST("/workflows/:id/start", wfInstH.Start) protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus) protected.POST("/channels/:id/workflow/advance", wfInstH.Advance) diff --git a/server/main.go b/server/main.go index 867d1e0..ee1eed8 100644 --- a/server/main.go +++ b/server/main.go @@ -160,12 +160,15 @@ func main() { } // Background workflow staleness sweep (v0.26.2): mark idle instances as stale + // + v0.27.0: retention enforcement — archive/delete completed instances per workflow policy if cfg.WorkflowStaleHours > 0 { go func() { ticker := time.NewTicker(1 * time.Hour) defer ticker.Stop() for { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + + // Staleness: mark idle active instances as stale cutoff := time.Now().UTC().Add(-time.Duration(cfg.WorkflowStaleHours) * time.Hour) res, err := database.DB.ExecContext(ctx, database.Q(` UPDATE channels @@ -179,6 +182,33 @@ func main() { } else if n, _ := res.RowsAffected(); n > 0 { log.Printf("🧹 workflows: marked %d instances as stale", n) } + + // v0.27.0: Retention enforcement — delete completed workflow channels + // where the parent workflow has retention.mode="delete" and + // retention.delete_after_days has elapsed since completion. + res2, err := database.DB.ExecContext(ctx, database.Q(` + DELETE FROM channels + WHERE type = 'workflow' + AND workflow_status IN ('completed', 'archived') + AND workflow_id IS NOT NULL + AND last_activity_at < $1 + AND workflow_id IN ( + SELECT id FROM workflows + WHERE retention IS NOT NULL + AND retention->>'mode' = 'delete' + AND (retention->>'delete_after_days')::int > 0 + AND channels.last_activity_at < now() - ((retention->>'delete_after_days')::int || ' days')::interval + ) + `), cutoff) + if err != nil { + // SQLite doesn't support JSON operators — skip retention on SQLite + if !database.IsSQLite() { + log.Printf("⚠ workflow retention enforcement failed: %v", err) + } + } else if n, _ := res2.RowsAffected(); n > 0 { + log.Printf("🧹 workflows: deleted %d expired instances (retention policy)", n) + } + cancel() <-ticker.C } @@ -554,7 +584,7 @@ func main() { protected.GET("/workflows/:id/versions/:version", wfH.GetVersion) // Workflow instances (v0.26.2 — runtime lifecycle) - wfInstH := handlers.NewWorkflowInstanceHandler(stores) + wfInstH := handlers.NewWorkflowInstanceHandler(stores, hub, notifSvc) protected.POST("/workflows/:id/start", wfInstH.Start) protected.GET("/channels/:id/workflow/status", wfInstH.GetStatus) protected.POST("/channels/:id/workflow/advance", wfInstH.Advance) diff --git a/server/pages/templates/surfaces/chat.html b/server/pages/templates/surfaces/chat.html index c83b0c2..b99c530 100644 --- a/server/pages/templates/surfaces/chat.html +++ b/server/pages/templates/surfaces/chat.html @@ -243,6 +243,9 @@ window.addEventListener('unhandledrejection', function(e) { {{/* v0.23.2: Channel context banner — shown for DMs and named channels */}}
+ {{/* v0.27.0: Workflow stage indicator — shown for workflow channels */}} + + {{/* Messages */}}