Changeset 0.27.1 (#167)
This commit is contained in:
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -21,10 +23,12 @@ import (
|
||||
|
||||
type WorkflowInstanceHandler struct {
|
||||
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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -243,6 +243,9 @@ window.addEventListener('unhandledrejection', function(e) {
|
||||
{{/* v0.23.2: Channel context banner — shown for DMs and named channels */}}
|
||||
<div id="channelContextBanner" class="channel-context-banner" style="display:none;"></div>
|
||||
|
||||
{{/* v0.27.0: Workflow stage indicator — shown for workflow channels */}}
|
||||
<div id="workflowStageBar" class="workflow-stage-bar" style="display:none;"></div>
|
||||
|
||||
{{/* Messages */}}
|
||||
<div id="chatMessages" class="msg-container">
|
||||
{{/* Populated by UI.renderMessages() */}}
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<a href="{{$base}}/settings/personas" class="settings-nav-link{{if eq $section "personas"}} active{{end}}">Personas</a>
|
||||
<a href="{{$base}}/settings/profile" class="settings-nav-link{{if eq $section "profile"}} active{{end}}">Profile</a>
|
||||
<a href="{{$base}}/settings/teams" class="settings-nav-link{{if eq $section "teams"}} active{{end}}">Teams</a>
|
||||
<a href="{{$base}}/settings/workflows" class="settings-nav-link{{if eq $section "workflows"}} active{{end}}">Workflows</a>
|
||||
|
||||
{{/* BYOK-gated nav items */}}
|
||||
<div id="settingsByokNav" style="display:none;">
|
||||
@@ -55,7 +56,7 @@
|
||||
{{/* Content */}}
|
||||
<div class="settings-content" id="settingsContentMount">
|
||||
<div id="settingsSection" data-section="{{.Section}}">
|
||||
<h2 id="settingsSectionTitle">{{if eq .Section "general"}}General{{else if eq .Section "appearance"}}Appearance{{else if eq .Section "models"}}Models{{else if eq .Section "personas"}}Personas{{else if eq .Section "profile"}}Profile{{else if eq .Section "teams"}}Teams{{else if eq .Section "providers"}}My Providers{{else if eq .Section "roles"}}Model Roles{{else if eq .Section "usage"}}My Usage{{else if eq .Section "knowledge"}}Knowledge Bases{{else if eq .Section "memory"}}Memory{{else if eq .Section "notifications"}}Notifications{{else}}Settings{{end}}</h2>
|
||||
<h2 id="settingsSectionTitle">{{if eq .Section "general"}}General{{else if eq .Section "appearance"}}Appearance{{else if eq .Section "models"}}Models{{else if eq .Section "personas"}}Personas{{else if eq .Section "profile"}}Profile{{else if eq .Section "teams"}}Teams{{else if eq .Section "workflows"}}Workflows{{else if eq .Section "providers"}}My Providers{{else if eq .Section "roles"}}Model Roles{{else if eq .Section "usage"}}My Usage{{else if eq .Section "knowledge"}}Knowledge Bases{{else if eq .Section "memory"}}Memory{{else if eq .Section "notifications"}}Notifications{{else}}Settings{{end}}</h2>
|
||||
|
||||
{{if eq .Section "general"}}
|
||||
<div class="settings-section">
|
||||
@@ -151,6 +152,8 @@
|
||||
<div id="myUsageTotals"></div>
|
||||
{{else if eq .Section "teams"}}
|
||||
<div id="settingsDynamic"><div class="settings-placeholder">Loading teams…</div></div>
|
||||
{{else if eq .Section "workflows"}}
|
||||
<div id="settingsDynamic"><div class="settings-placeholder">Loading workflows…</div></div>
|
||||
{{else}}<div class="settings-placeholder" id="settingsDynamic">Loading…</div>
|
||||
{{end}}
|
||||
</div>
|
||||
@@ -247,6 +250,7 @@
|
||||
memory: () => typeof MemoryUI !== 'undefined' && MemoryUI.openSettingsPanel?.(),
|
||||
notifications: () => typeof NotifPrefs !== 'undefined' && NotifPrefs.load?.(),
|
||||
teams: () => typeof UI !== 'undefined' && UI.loadTeamsSettings?.(),
|
||||
workflows: () => typeof loadTeamWorkflows === 'function' && loadTeamWorkflows(),
|
||||
};
|
||||
|
||||
// Populate App.policies and App.models from API.
|
||||
|
||||
@@ -221,23 +221,27 @@ func CreateWorkflowStageNote(ctx context.Context, stores store.Stores, channelID
|
||||
}
|
||||
|
||||
// CreateWorkflowAssignment creates an unassigned workflow assignment entry.
|
||||
func CreateWorkflowAssignment(ctx context.Context, channelID string, stage int, teamID string) {
|
||||
if database.IsSQLite() {
|
||||
// Returns the assignment ID (for round-robin auto-assignment).
|
||||
func CreateWorkflowAssignment(ctx context.Context, channelID string, stage int, teamID string) string {
|
||||
id := store.NewID()
|
||||
if database.IsSQLite() {
|
||||
_, err := database.DB.ExecContext(ctx, `
|
||||
INSERT INTO workflow_assignments (id, channel_id, stage, team_id)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, id, channelID, stage, teamID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to create workflow assignment: %v", err)
|
||||
return ""
|
||||
}
|
||||
return
|
||||
return id
|
||||
}
|
||||
_, err := database.DB.ExecContext(ctx, `
|
||||
INSERT INTO workflow_assignments (channel_id, stage, team_id)
|
||||
VALUES ($1, $2, $3)
|
||||
`, channelID, stage, teamID)
|
||||
INSERT INTO workflow_assignments (id, channel_id, stage, team_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`, id, channelID, stage, teamID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to create workflow assignment: %v", err)
|
||||
return ""
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package workspace
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
@@ -452,6 +453,9 @@ func (fs *FS) Tree(ctx context.Context, w *models.Workspace) ([]models.Workspace
|
||||
func (fs *FS) Reconcile(ctx context.Context, w *models.Workspace) (added, removed, updated int, err error) {
|
||||
root := fs.filesDir(w)
|
||||
|
||||
// v0.27.0: Load .gitignore patterns from workspace root
|
||||
ignorePatterns := loadGitignore(root)
|
||||
|
||||
// Walk FS and collect all paths
|
||||
fsPaths := make(map[string]os.FileInfo)
|
||||
err = filepath.Walk(root, func(abs string, info os.FileInfo, walkErr error) error {
|
||||
@@ -467,6 +471,15 @@ func (fs *FS) Reconcile(ctx context.Context, w *models.Workspace) (added, remove
|
||||
}
|
||||
// Normalize to forward slashes
|
||||
rel = filepath.ToSlash(rel)
|
||||
|
||||
// v0.27.0: Skip gitignored paths
|
||||
if matchesGitignore(rel, info.IsDir(), ignorePatterns) {
|
||||
if info.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
fsPaths[rel] = info
|
||||
return nil
|
||||
})
|
||||
@@ -624,3 +637,80 @@ func hashFile(absPath string) (string, error) {
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// ── Gitignore ───────────────────────────────
|
||||
// v0.27.0: Simple .gitignore parser for workspace indexing.
|
||||
// Supports glob patterns, directory-only patterns (trailing /),
|
||||
// negation (!), and comments (#). Does not support ** (double-star).
|
||||
|
||||
type gitignorePattern struct {
|
||||
pattern string
|
||||
negate bool
|
||||
dirOnly bool
|
||||
}
|
||||
|
||||
// loadGitignore reads .gitignore from the workspace root.
|
||||
// Returns nil if the file doesn't exist.
|
||||
func loadGitignore(root string) []gitignorePattern {
|
||||
f, err := os.Open(filepath.Join(root, ".gitignore"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var patterns []gitignorePattern
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || line[0] == '#' {
|
||||
continue
|
||||
}
|
||||
p := gitignorePattern{}
|
||||
if line[0] == '!' {
|
||||
p.negate = true
|
||||
line = line[1:]
|
||||
}
|
||||
if strings.HasSuffix(line, "/") {
|
||||
p.dirOnly = true
|
||||
line = strings.TrimSuffix(line, "/")
|
||||
}
|
||||
p.pattern = line
|
||||
patterns = append(patterns, p)
|
||||
}
|
||||
|
||||
// Always ignore .git directory
|
||||
patterns = append([]gitignorePattern{{pattern: ".git", dirOnly: true}}, patterns...)
|
||||
return patterns
|
||||
}
|
||||
|
||||
// matchesGitignore checks if a relative path matches any gitignore pattern.
|
||||
// Last matching pattern wins (same as git semantics).
|
||||
func matchesGitignore(relPath string, isDir bool, patterns []gitignorePattern) bool {
|
||||
if len(patterns) == 0 {
|
||||
return false
|
||||
}
|
||||
ignored := false
|
||||
basename := path.Base(relPath)
|
||||
for _, p := range patterns {
|
||||
if p.dirOnly && !isDir {
|
||||
continue
|
||||
}
|
||||
// Match against basename or full relative path
|
||||
matched := false
|
||||
if strings.Contains(p.pattern, "/") {
|
||||
// Pattern with slash — match against full path
|
||||
matched, _ = filepath.Match(p.pattern, relPath)
|
||||
} else {
|
||||
// Pattern without slash — match against basename
|
||||
matched, _ = filepath.Match(p.pattern, basename)
|
||||
if !matched {
|
||||
// Also try against full path for prefix directory matches
|
||||
matched, _ = filepath.Match(p.pattern, relPath)
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
ignored = !p.negate
|
||||
}
|
||||
}
|
||||
return ignored
|
||||
}
|
||||
|
||||
@@ -78,3 +78,46 @@
|
||||
.badge { font-size: 11px; padding: 2px 8px; border-radius: 10px; background: var(--bg-raised); color: var(--text-2); }
|
||||
.badge-ok { background: rgba(46, 160, 67, 0.15); color: #2ea043; }
|
||||
.badge-warn { background: rgba(210, 153, 34, 0.15); color: #d29922; }
|
||||
|
||||
/* ── v0.27.0: Workflow stage indicator bar ── */
|
||||
|
||||
.workflow-stage-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wf-bar-steps {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
.wf-bar-step {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--border);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.wf-bar-step.completed { background: var(--success); }
|
||||
.wf-bar-step.current { background: var(--accent); box-shadow: 0 0 0 2px var(--accent-dim); }
|
||||
.wf-bar-stage-name { font-weight: 600; color: var(--text); }
|
||||
.wf-bar-status {
|
||||
font-size: 11px;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.wf-bar-status.active { background: var(--accent-dim); color: var(--accent); }
|
||||
.wf-bar-status.completed { background: var(--success-dim); color: var(--success-light); }
|
||||
.wf-bar-status.stale { background: var(--warning-dim); color: var(--warning-light); }
|
||||
.wf-bar-spacer { flex: 1; }
|
||||
.wf-bar-actions { display: flex; gap: 6px; }
|
||||
.wf-bar-actions .btn-small { font-size: 11px; padding: 3px 10px; }
|
||||
|
||||
/* DnD stage reorder feedback */
|
||||
.wf-stage-row.dragging { opacity: 0.4; }
|
||||
.wf-stage-row.drag-over { border-top: 2px solid var(--accent); }
|
||||
|
||||
@@ -1179,6 +1179,36 @@ function _initChatListeners() {
|
||||
if (typeof UI !== 'undefined') UI.renderChannelsSection();
|
||||
});
|
||||
|
||||
// v0.27.0: Workflow stage advanced — refresh stage bar + auto-switch persona
|
||||
Events.on('workflow.advanced', (payload) => {
|
||||
if (!payload) return;
|
||||
const ac = App.activeConversation;
|
||||
if (ac && ac.id === payload.channel_id) {
|
||||
UI.updateWorkflowStageBar();
|
||||
}
|
||||
// Refresh queue sidebar if present
|
||||
if (typeof WorkflowQueue !== 'undefined') WorkflowQueue.refresh();
|
||||
});
|
||||
|
||||
// v0.27.0: Workflow completed — refresh stage bar
|
||||
Events.on('workflow.completed', (payload) => {
|
||||
if (!payload) return;
|
||||
const ac = App.activeConversation;
|
||||
if (ac && ac.id === payload.channel_id) {
|
||||
UI.updateWorkflowStageBar();
|
||||
}
|
||||
if (typeof WorkflowQueue !== 'undefined') WorkflowQueue.refresh();
|
||||
});
|
||||
|
||||
// v0.27.0: Workflow assigned — refresh queue + toast
|
||||
Events.on('workflow.assigned', (payload) => {
|
||||
if (!payload) return;
|
||||
if (payload.assigned_to === (API.user && API.user.id)) {
|
||||
UI.toast('Workflow stage "' + (payload.stage_name || '?') + '" assigned to you', 'info');
|
||||
}
|
||||
if (typeof WorkflowQueue !== 'undefined') WorkflowQueue.refresh();
|
||||
});
|
||||
|
||||
// Close modals on overlay click
|
||||
document.querySelectorAll('.modal-overlay').forEach(overlay => {
|
||||
overlay.addEventListener('click', (e) => {
|
||||
@@ -1186,3 +1216,33 @@ function _initChatListeners() {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// v0.27.0: Advance workflow stage (called from stage bar button)
|
||||
async function advanceWorkflowStage() {
|
||||
var ac = App.activeConversation;
|
||||
if (!ac || ac.type !== 'workflow') return;
|
||||
var confirmed = await showConfirm('Advance to the next stage?', { title: 'Advance Workflow', danger: false, ok: 'Advance' });
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
var resp = await API._post('/api/v1/channels/' + ac.id + '/workflow/advance', {});
|
||||
UI.toast('Advanced to stage: ' + (resp.stage?.name || resp.current_stage), 'success');
|
||||
UI.updateWorkflowStageBar();
|
||||
} catch (e) {
|
||||
UI.toast('Advance failed: ' + (e.message || e), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// v0.27.0: Reject workflow stage (called from stage bar button)
|
||||
async function rejectWorkflowStage() {
|
||||
var ac = App.activeConversation;
|
||||
if (!ac || ac.type !== 'workflow') return;
|
||||
var reason = await showPrompt({ title: 'Reject Stage', label: 'Reason for rejection:', placeholder: 'Enter reason...', ok: 'Reject' });
|
||||
if (!reason) return;
|
||||
try {
|
||||
var resp = await API._post('/api/v1/channels/' + ac.id + '/workflow/reject', { reason: reason });
|
||||
UI.toast('Rejected to stage ' + resp.current_stage + ': ' + reason, 'warning');
|
||||
UI.updateWorkflowStageBar();
|
||||
} catch (e) {
|
||||
UI.toast('Reject failed: ' + (e.message || e), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,6 +440,48 @@ function _resetPaneSize(el) {
|
||||
|
||||
// ── Persistence ─────────────────────────────
|
||||
|
||||
// v0.27.0: Debounced server sync for cross-device pane layout persistence.
|
||||
var _paneSyncTimer = null;
|
||||
function _syncPaneLayoutsToServer() {
|
||||
if (_paneSyncTimer) clearTimeout(_paneSyncTimer);
|
||||
_paneSyncTimer = setTimeout(function() {
|
||||
try {
|
||||
// Collect all sb_layout_* keys
|
||||
var layouts = {};
|
||||
for (var i = 0; i < localStorage.length; i++) {
|
||||
var k = localStorage.key(i);
|
||||
if (k && k.indexOf('sb_layout_') === 0) {
|
||||
try { layouts[k] = JSON.parse(localStorage.getItem(k)); } catch (_) {}
|
||||
}
|
||||
}
|
||||
if (typeof API !== 'undefined' && API._put) {
|
||||
API._put('/api/v1/settings', { pane_layouts: layouts }).catch(function() {});
|
||||
}
|
||||
} catch (_) {}
|
||||
}, 2000); // 2s debounce — avoid hammering during resize drags
|
||||
}
|
||||
|
||||
// v0.27.0: On page load, restore pane layouts from server if available.
|
||||
// Called once from PaneContainer init (or surface boot).
|
||||
function _loadPaneLayoutsFromServer() {
|
||||
if (typeof API === 'undefined' || !API._get) return;
|
||||
API._get('/api/v1/settings').then(function(settings) {
|
||||
if (!settings || !settings.pane_layouts) return;
|
||||
var layouts = settings.pane_layouts;
|
||||
for (var key in layouts) {
|
||||
if (key.indexOf('sb_layout_') === 0 && layouts[key]) {
|
||||
// Only overwrite if localStorage is empty for this key
|
||||
if (!localStorage.getItem(key)) {
|
||||
localStorage.setItem(key, JSON.stringify(layouts[key]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}).catch(function() {});
|
||||
}
|
||||
|
||||
// Kick off server layout load on script execution
|
||||
if (typeof API !== 'undefined') _loadPaneLayoutsFromServer();
|
||||
|
||||
function _persistSizes(surfaceId, splitEl) {
|
||||
const key = 'sb_layout_' + surfaceId;
|
||||
try {
|
||||
@@ -456,6 +498,9 @@ function _persistSizes(surfaceId, splitEl) {
|
||||
try { existing = JSON.parse(localStorage.getItem(key) || '{}'); } catch (_) {}
|
||||
Object.assign(existing, sizes);
|
||||
localStorage.setItem(key, JSON.stringify(existing));
|
||||
|
||||
// v0.27.0: Debounced sync to server for cross-device persistence
|
||||
_syncPaneLayoutsToServer();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -834,3 +834,61 @@ if (typeof deleteUserPersona === 'undefined') {
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
};
|
||||
}
|
||||
|
||||
// v0.27.0: Team Workflows settings section — lists workflows for user's teams.
|
||||
// Loaded as a dynamic section in the settings surface.
|
||||
window.loadTeamWorkflows = async function() {
|
||||
var el = document.getElementById('settingsDynamic');
|
||||
if (!el) return;
|
||||
|
||||
try {
|
||||
var teamsResp = await API.listMyTeams();
|
||||
var teams = teamsResp.data || teamsResp || [];
|
||||
if (teams.length === 0) {
|
||||
el.innerHTML = '<div class="settings-placeholder">You are not a member of any teams.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '<p style="font-size:13px;color:var(--text-2);margin-bottom:20px;">' +
|
||||
'Manage workflows owned by your teams. Use the Admin panel for global workflows.</p>';
|
||||
|
||||
for (var t = 0; t < teams.length; t++) {
|
||||
var team = teams[t];
|
||||
var wfResp;
|
||||
try {
|
||||
wfResp = await API.listWorkflows(team.id);
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
var workflows = wfResp.data || wfResp || [];
|
||||
|
||||
html += '<div class="settings-section" style="margin-bottom:16px;">' +
|
||||
'<h4 style="margin:0 0 10px;font-size:14px;">' + esc(team.name) + '</h4>';
|
||||
|
||||
if (workflows.length === 0) {
|
||||
html += '<div class="settings-placeholder" style="padding:12px;">No workflows for this team.</div>';
|
||||
} else {
|
||||
workflows.forEach(function(wf) {
|
||||
var statusBadge = wf.is_active ?
|
||||
'<span class="badge badge-ok">Active</span>' :
|
||||
'<span class="badge">Inactive</span>';
|
||||
var base = window.__BASE__ || '';
|
||||
html += '<div style="display:flex;align-items:center;gap:10px;padding:10px 12px;' +
|
||||
'background:var(--bg-surface);border:1px solid var(--border);border-radius:8px;margin-bottom:6px;">' +
|
||||
'<span style="flex:1;font-weight:500;">' + esc(wf.name) + '</span>' +
|
||||
statusBadge +
|
||||
'<span class="badge">v' + wf.version + '</span>' +
|
||||
'<span style="font-size:11px;color:var(--text-3);">' + esc(wf.slug) + '</span>' +
|
||||
(API.isAdmin ?
|
||||
'<a href="' + base + '/admin/workflows" class="btn-small" style="text-decoration:none;">Edit in Admin</a>' : '') +
|
||||
'</div>';
|
||||
});
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
el.innerHTML = html;
|
||||
} catch (e) {
|
||||
el.innerHTML = '<div class="settings-placeholder">Failed to load workflows: ' + esc(e.message) + '</div>';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -823,6 +823,7 @@ const UI = {
|
||||
const ac = App.activeConversation;
|
||||
if (!ac || ac.type === 'direct') {
|
||||
el.style.display = 'none';
|
||||
this.updateWorkflowStageBar();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -852,6 +853,71 @@ const UI = {
|
||||
}
|
||||
el.innerHTML = html;
|
||||
el.style.display = '';
|
||||
this.updateWorkflowStageBar();
|
||||
},
|
||||
// Fetches workflow status and renders stage dots, name, advance/reject buttons.
|
||||
updateWorkflowStageBar() {
|
||||
const el = document.getElementById('workflowStageBar');
|
||||
if (!el) return;
|
||||
|
||||
const ac = App.activeConversation;
|
||||
if (!ac || ac.type !== 'workflow') {
|
||||
el.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch status (async, non-blocking — renders on completion)
|
||||
const base = window.__BASE__ || '';
|
||||
API._get(`/api/v1/channels/${ac.id}/workflow/status`).then(ws => {
|
||||
if (!ws || !ws.workflow_id) {
|
||||
el.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache on App for WS event handler access
|
||||
App._workflowStatus = ws;
|
||||
|
||||
// Load stage definitions for name display
|
||||
API._get(`/api/v1/workflows/${ws.workflow_id}`).then(wf => {
|
||||
const stages = wf.stages || [];
|
||||
const current = ws.current_stage || 0;
|
||||
const status = ws.status || 'active';
|
||||
const totalStages = stages.length || 1;
|
||||
const stageName = stages[current] ? stages[current].name : 'Stage ' + current;
|
||||
|
||||
// Step dots
|
||||
let dots = '';
|
||||
for (let i = 0; i < totalStages; i++) {
|
||||
const cls = i < current ? 'completed' : i === current ? 'current' : '';
|
||||
dots += '<div class="wf-bar-step ' + cls + '" title="Stage ' + i + (stages[i] ? ': ' + esc(stages[i].name) : '') + '"></div>';
|
||||
}
|
||||
|
||||
// Status badge
|
||||
const statusCls = status === 'completed' ? 'completed' : status === 'stale' ? 'stale' : 'active';
|
||||
const statusLabel = status.charAt(0).toUpperCase() + status.slice(1);
|
||||
|
||||
// Advance/reject buttons (only for active workflows, admin or team members)
|
||||
let actions = '';
|
||||
if (status === 'active' && API.isAdmin) {
|
||||
actions =
|
||||
'<button class="btn-small btn-primary" onclick="advanceWorkflowStage()">Advance ▸</button>' +
|
||||
(current > 0 ? '<button class="btn-small btn-danger" onclick="rejectWorkflowStage()">◂ Reject</button>' : '');
|
||||
}
|
||||
|
||||
el.innerHTML =
|
||||
'<div class="wf-bar-steps">' + dots + '</div>' +
|
||||
'<span class="wf-bar-stage-name">' + esc(stageName) + '</span>' +
|
||||
'<span class="wf-bar-status ' + statusCls + '">' + esc(statusLabel) + '</span>' +
|
||||
'<span class="wf-bar-spacer"></span>' +
|
||||
'<span style="font-size:11px;color:var(--text-3)">Step ' + (current + 1) + ' of ' + totalStages + '</span>' +
|
||||
(actions ? '<div class="wf-bar-actions">' + actions + '</div>' : '');
|
||||
el.style.display = '';
|
||||
}).catch(() => {
|
||||
el.style.display = 'none';
|
||||
});
|
||||
}).catch(() => {
|
||||
el.style.display = 'none';
|
||||
});
|
||||
},
|
||||
|
||||
showEditInline(messageId, currentContent) {
|
||||
|
||||
@@ -260,6 +260,9 @@
|
||||
'</div>';
|
||||
});
|
||||
el.innerHTML = html;
|
||||
|
||||
// v0.27.0: Wire DnD reorder on stage rows
|
||||
_wfWireStageDnD(el, wfId);
|
||||
} catch (e) {
|
||||
el.innerHTML = '<div class="empty-hint">Failed: ' + esc(e.message) + '</div>';
|
||||
}
|
||||
@@ -267,6 +270,61 @@
|
||||
|
||||
// ── Wire Events (called once per detail open) ──
|
||||
|
||||
// v0.27.0: Wire drag-and-drop reorder on stage rows
|
||||
function _wfWireStageDnD(container, wfId) {
|
||||
var dragSrc = null;
|
||||
container.querySelectorAll('.wf-stage-row').forEach(function(row) {
|
||||
row.addEventListener('dragstart', function(e) {
|
||||
dragSrc = row;
|
||||
row.classList.add('dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', row.dataset.id);
|
||||
});
|
||||
row.addEventListener('dragend', function() {
|
||||
row.classList.remove('dragging');
|
||||
container.querySelectorAll('.wf-stage-row').forEach(function(r) {
|
||||
r.classList.remove('drag-over');
|
||||
});
|
||||
dragSrc = null;
|
||||
});
|
||||
row.addEventListener('dragover', function(e) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
if (row !== dragSrc) row.classList.add('drag-over');
|
||||
});
|
||||
row.addEventListener('dragleave', function() {
|
||||
row.classList.remove('drag-over');
|
||||
});
|
||||
row.addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
row.classList.remove('drag-over');
|
||||
if (!dragSrc || dragSrc === row) return;
|
||||
|
||||
// Reorder in DOM
|
||||
var rows = Array.from(container.querySelectorAll('.wf-stage-row'));
|
||||
var fromIdx = rows.indexOf(dragSrc);
|
||||
var toIdx = rows.indexOf(row);
|
||||
if (fromIdx < toIdx) {
|
||||
row.parentNode.insertBefore(dragSrc, row.nextSibling);
|
||||
} else {
|
||||
row.parentNode.insertBefore(dragSrc, row);
|
||||
}
|
||||
|
||||
// Collect new order and send to backend
|
||||
var orderedIDs = Array.from(container.querySelectorAll('.wf-stage-row')).map(function(r) {
|
||||
return r.dataset.id;
|
||||
});
|
||||
API.reorderStages(wfId, orderedIDs).then(function() {
|
||||
UI.toast('Stages reordered', 'success');
|
||||
_wfLoadStages(wfId);
|
||||
}).catch(function(err) {
|
||||
UI.toast('Reorder failed: ' + (err.message || err), 'error');
|
||||
_wfLoadStages(wfId);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
var _wired = false;
|
||||
function _wfWireEvents() {
|
||||
if (_wired) return;
|
||||
|
||||
Reference in New Issue
Block a user