diff --git a/cmd/pad/main.go b/cmd/pad/main.go index 3dbf22362..76c4f5c2a 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -2101,6 +2101,7 @@ func updateCmd() *cobra.Command { category string tags string fieldFlags []string + comment string ) cmd := &cobra.Command{ @@ -2112,6 +2113,7 @@ Items can be referenced by issue ID (e.g. TASK-5) or slug. Examples: pad item update TASK-5 --status done + pad item update TASK-5 --status done --comment "Fixed the login bug" pad item update PHASE-2 --status active --priority high pad item update DOC-3 --stdin < updated-doc.md`, Args: cobra.ExactArgs(1), @@ -2147,6 +2149,9 @@ Examples: if tags != "" { input.Tags = &tags } + if comment != "" { + input.Comment = &comment + } // Merge field changes with existing fields hasFieldChanges := status != "" || priority != "" || assignee != "" || phase != "" || category != "" || len(fieldFlags) > 0 @@ -2221,6 +2226,7 @@ Examples: cmd.Flags().StringVar(&category, "category", "", "update category field") cmd.Flags().StringVar(&tags, "tags", "", "update tags (JSON array)") cmd.Flags().StringArrayVarP(&fieldFlags, "field", "f", nil, "set arbitrary field (repeatable): --field key=value") + cmd.Flags().StringVar(&comment, "comment", "", "attach a comment explaining this update (e.g. why status changed)") return cmd } @@ -2314,7 +2320,9 @@ Examples: // --- comments --- func commentCmd() *cobra.Command { - return &cobra.Command{ + var replyTo string + + cmd := &cobra.Command{ Use: "comment ", Short: "Add a comment to an item", Args: cobra.ExactArgs(2), @@ -2323,7 +2331,8 @@ func commentCmd() *cobra.Command { ws := getWorkspace() input := models.CommentCreate{ - Body: args[1], + Body: args[1], + ParentID: replyTo, } comment, err := client.CreateComment(ws, args[0], input) @@ -2339,6 +2348,9 @@ func commentCmd() *cobra.Command { return nil }, } + + cmd.Flags().StringVar(&replyTo, "reply-to", "", "reply to a specific comment ID") + return cmd } func commentsCmd() *cobra.Command { diff --git a/internal/events/bus.go b/internal/events/bus.go index 6c0920f01..8660b5c36 100644 --- a/internal/events/bus.go +++ b/internal/events/bus.go @@ -23,6 +23,13 @@ const ( // Comment events CommentCreated = "comment_created" CommentDeleted = "comment_deleted" + + // Reaction events + ReactionAdded = "reaction_added" + ReactionRemoved = "reaction_removed" + + // Composite events + ItemUpdatedWithComment = "item_updated_with_comment" ) // Event represents a real-time event published when state changes occur. diff --git a/internal/models/activity.go b/internal/models/activity.go index 05a4f3cc7..efebd5b80 100644 --- a/internal/models/activity.go +++ b/internal/models/activity.go @@ -32,3 +32,23 @@ type ActivityListParams struct { Limit int Offset int } + +// TimelineEntry represents a single entry in the unified item timeline. +// It wraps one of: a comment, an activity, or a version. +type TimelineEntry struct { + ID string `json:"id"` + Kind string `json:"kind"` // "comment", "activity", "version" + CreatedAt time.Time `json:"created_at"` + Actor string `json:"actor"` + ActorName string `json:"actor_name,omitempty"` + Source string `json:"source"` + Comment *Comment `json:"comment,omitempty"` + Activity *Activity `json:"activity,omitempty"` + Version *Version `json:"version,omitempty"` +} + +// TimelineResponse is the paginated response from the timeline endpoint. +type TimelineResponse struct { + Entries []TimelineEntry `json:"entries"` + Total int `json:"total"` +} diff --git a/internal/models/comment.go b/internal/models/comment.go index c473db417..ac5603b6b 100644 --- a/internal/models/comment.go +++ b/internal/models/comment.go @@ -11,18 +11,37 @@ type Comment struct { Body string `json:"body"` CreatedBy string `json:"created_by"` Source string `json:"source"` + ActivityID string `json:"activity_id,omitempty"` + ParentID string `json:"parent_id,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` // Populated by joins (not stored) ItemTitle string `json:"item_title,omitempty"` ItemSlug string `json:"item_slug,omitempty"` + + // Populated by handlers for threaded views + Replies []Comment `json:"replies,omitempty"` + Reactions []Reaction `json:"reactions,omitempty"` } // CommentCreate is the input for creating a new comment. type CommentCreate struct { - Author string `json:"author,omitempty"` - Body string `json:"body"` - CreatedBy string `json:"created_by,omitempty"` - Source string `json:"source,omitempty"` + Author string `json:"author,omitempty"` + Body string `json:"body"` + CreatedBy string `json:"created_by,omitempty"` + Source string `json:"source,omitempty"` + ParentID string `json:"parent_id,omitempty"` + ActivityID string `json:"activity_id,omitempty"` +} + +// Reaction represents an emoji reaction on a comment. +type Reaction struct { + ID string `json:"id"` + CommentID string `json:"comment_id"` + UserID string `json:"user_id,omitempty"` + Actor string `json:"actor"` + Emoji string `json:"emoji"` + CreatedAt time.Time `json:"created_at"` + ActorName string `json:"actor_name,omitempty"` } diff --git a/internal/models/item.go b/internal/models/item.go index 0bf6c324e..6f3e1d3fc 100644 --- a/internal/models/item.go +++ b/internal/models/item.go @@ -473,6 +473,7 @@ type ItemUpdate struct { LastModifiedBy string `json:"last_modified_by,omitempty"` Source string `json:"source,omitempty"` ChangeSummary string `json:"change_summary,omitempty"` + Comment *string `json:"comment,omitempty"` } type ItemListParams struct { diff --git a/internal/server/handlers_comments.go b/internal/server/handlers_comments.go index e9491d775..39274afc7 100644 --- a/internal/server/handlers_comments.go +++ b/internal/server/handlers_comments.go @@ -2,7 +2,9 @@ package server import ( "database/sql" + "encoding/json" "net/http" + "strings" "github.com/go-chi/chi/v5" @@ -37,6 +39,22 @@ func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) { comments = []models.Comment{} } + // Bulk-load reactions for all comments. + if len(comments) > 0 { + commentIDs := make([]string, len(comments)) + for i, c := range comments { + commentIDs[i] = c.ID + } + reactionsMap, err := s.store.ListReactionsByComments(commentIDs) + if err == nil && reactionsMap != nil { + for i := range comments { + if reactions, ok := reactionsMap[comments[i].ID]; ok { + comments[i].Reactions = reactions + } + } + } + } + writeJSON(w, http.StatusOK, comments) } @@ -101,12 +119,20 @@ func (s *Server) handleCreateComment(w http.ResponseWriter, r *http.Request) { // handleDeleteComment removes a comment. func (s *Server) handleDeleteComment(w http.ResponseWriter, r *http.Request) { - _, ok := s.getWorkspaceID(w, r) + workspaceID, ok := s.getWorkspaceID(w, r) if !ok { return } commentID := chi.URLParam(r, "commentID") + + // Verify the comment belongs to this workspace. + comment, cerr := s.store.GetComment(commentID) + if cerr != nil || comment == nil || comment.WorkspaceID != workspaceID { + writeError(w, http.StatusNotFound, "not_found", "Comment not found") + return + } + if err := s.store.DeleteComment(commentID); err != nil { if err == sql.ErrNoRows { writeError(w, http.StatusNotFound, "not_found", "Comment not found") @@ -119,6 +145,138 @@ func (s *Server) handleDeleteComment(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// handleCreateReply creates a reply to an existing comment. +func (s *Server) handleCreateReply(w http.ResponseWriter, r *http.Request) { + workspaceID, ok := s.getWorkspaceID(w, r) + if !ok { + return + } + + commentID := chi.URLParam(r, "commentID") + parentComment, err := s.store.GetComment(commentID) + if err != nil || parentComment == nil { + writeError(w, http.StatusNotFound, "not_found", "Parent comment not found") + return + } + if parentComment.WorkspaceID != workspaceID { + writeError(w, http.StatusNotFound, "not_found", "Parent comment not found") + return + } + + var input models.CommentCreate + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, http.StatusBadRequest, "invalid_json", "Invalid JSON body") + return + } + if strings.TrimSpace(input.Body) == "" { + writeError(w, http.StatusBadRequest, "validation_error", "body is required") + return + } + + // Set author from current user if not provided. + if input.Author == "" { + if u := currentUser(r); u != nil { + input.Author = u.Name + } + } + + actor, source := actorFromRequest(r) + if input.CreatedBy == "" { + input.CreatedBy = actor + } + if input.Source == "" { + input.Source = source + } + input.ParentID = commentID + + comment, err := s.store.CreateComment(workspaceID, parentComment.ItemID, input) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", err.Error()) + return + } + + s.publishCommentEvent(events.CommentCreated, workspaceID, parentComment.ItemID, comment.ID, parentComment.ItemTitle, "", actor, source) + + writeJSON(w, http.StatusCreated, comment) +} + +// handleAddReaction adds an emoji reaction to a comment. +func (s *Server) handleAddReaction(w http.ResponseWriter, r *http.Request) { + workspaceID, ok := s.getWorkspaceID(w, r) + if !ok { + return + } + + commentID := chi.URLParam(r, "commentID") + + // Verify the comment belongs to this workspace. + comment, err := s.store.GetComment(commentID) + if err != nil || comment == nil || comment.WorkspaceID != workspaceID { + writeError(w, http.StatusNotFound, "not_found", "Comment not found") + return + } + + var input struct { + Emoji string `json:"emoji"` + } + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, http.StatusBadRequest, "invalid_json", "Invalid JSON body") + return + } + if strings.TrimSpace(input.Emoji) == "" { + writeError(w, http.StatusBadRequest, "validation_error", "emoji is required") + return + } + + actor, _ := actorFromRequest(r) + userID := currentUserID(r) + + reaction, err := s.store.AddReaction(commentID, userID, actor, input.Emoji) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", err.Error()) + return + } + + // Fire SSE event for the reaction. + if parentComment, cerr := s.store.GetComment(commentID); cerr == nil { + s.publishReactionEvent(events.ReactionAdded, parentComment) + } + + writeJSON(w, http.StatusCreated, reaction) +} + +// handleRemoveReaction removes an emoji reaction from a comment. +func (s *Server) handleRemoveReaction(w http.ResponseWriter, r *http.Request) { + workspaceID, ok := s.getWorkspaceID(w, r) + if !ok { + return + } + + commentID := chi.URLParam(r, "commentID") + emoji := chi.URLParam(r, "emoji") + + // Verify the comment belongs to this workspace. + commentObj, cerr := s.store.GetComment(commentID) + if cerr != nil || commentObj == nil || commentObj.WorkspaceID != workspaceID { + writeError(w, http.StatusNotFound, "not_found", "Comment not found") + return + } + + userID := currentUserID(r) + + if err := s.store.RemoveReaction(commentID, userID, emoji); err != nil { + writeError(w, http.StatusNotFound, "not_found", "Reaction not found") + return + } + + // Fire SSE event for the reaction removal. + if parentComment, cerr := s.store.GetComment(commentID); cerr == nil { + s.publishReactionEvent(events.ReactionRemoved, parentComment) + } + + w.WriteHeader(http.StatusNoContent) +} + // publishCommentEvent publishes a real-time event for comment changes. func (s *Server) publishCommentEvent(eventType, workspaceID, itemID, commentID, title, collection, actor, source string) { if s.events == nil { @@ -134,3 +292,15 @@ func (s *Server) publishCommentEvent(eventType, workspaceID, itemID, commentID, Source: source, }) } + +// publishReactionEvent publishes a real-time event for reaction changes. +func (s *Server) publishReactionEvent(eventType string, comment *models.Comment) { + if s.events == nil || comment == nil { + return + } + s.events.Publish(events.Event{ + Type: eventType, + WorkspaceID: comment.WorkspaceID, + ItemID: comment.ItemID, + }) +} diff --git a/internal/server/handlers_documents.go b/internal/server/handlers_documents.go index b9d1729fb..c8a2f57f9 100644 --- a/internal/server/handlers_documents.go +++ b/internal/server/handlers_documents.go @@ -417,9 +417,15 @@ func (s *Server) logActivity(workspaceID, documentID, action string, r *http.Req // For "updated" actions, uses debouncing to coalesce rapid successive saves // (e.g., autosave) into a single activity entry within a cooldown window. func (s *Server) logActivityWithMeta(workspaceID, documentID, action string, r *http.Request, metadata string) { + _, _ = s.logActivityWithMetaReturningID(workspaceID, documentID, action, r, metadata) +} + +// logActivityWithMetaReturningID is like logActivityWithMeta but returns the activity ID. +// The ID is either newly created or the coalesced existing activity's ID (for debounced updates). +func (s *Server) logActivityWithMetaReturningID(workspaceID, documentID, action string, r *http.Request, metadata string) (string, error) { actor, source := actorFromRequest(r) metadata = agentMeta(r, metadata) - _ = s.store.CreateActivityDebounced(models.Activity{ + return s.store.CreateActivityDebounced(models.Activity{ WorkspaceID: workspaceID, DocumentID: documentID, Action: action, diff --git a/internal/server/handlers_items.go b/internal/server/handlers_items.go index f4ce73b2c..a0b3f4b17 100644 --- a/internal/server/handlers_items.go +++ b/internal/server/handlers_items.go @@ -4,6 +4,7 @@ import ( "database/sql" "encoding/json" "fmt" + "log" "net/http" "strconv" "strings" @@ -278,10 +279,36 @@ func (s *Server) handleUpdateItem(w http.ResponseWriter, r *http.Request) { } } actor, source := actorFromRequest(r) - s.logActivityWithMeta(workspaceID, updated.ID, "updated", r, meta) + activityID, _ := s.logActivityWithMetaReturningID(workspaceID, updated.ID, "updated", r, meta) s.publishItemEventWithName(events.ItemUpdated, workspaceID, updated.ID, updated.Title, updated.CollectionSlug, actor, actorNameFromRequest(r), source) s.dispatchWebhook(workspaceID, "item.updated", updated) + // If a comment was attached to this update (e.g. explaining a status change), + // create a comment linked to the activity entry. + if input.Comment != nil && strings.TrimSpace(*input.Comment) != "" { + commentInput := models.CommentCreate{ + Body: strings.TrimSpace(*input.Comment), + ActivityID: activityID, + } + if u := currentUser(r); u != nil { + commentInput.Author = u.Name + } + commentInput.CreatedBy = actor + commentInput.Source = source + comment, cerr := s.store.CreateComment(workspaceID, updated.ID, commentInput) + if cerr != nil { + log.Printf("WARNING: failed to create comment on item update %s: %v", updated.ID, cerr) + } + if cerr == nil && comment != nil { + s.publishCommentEvent(events.CommentCreated, workspaceID, updated.ID, comment.ID, updated.Title, updated.CollectionSlug, actor, source) + s.dispatchWebhook(workspaceID, "item.updated_with_comment", map[string]interface{}{ + "item": updated, + "comment": comment, + "changes": meta, + }) + } + } + if err := s.enrichItemForResponse(updated); err != nil { writeError(w, http.StatusInternalServerError, "internal_error", err.Error()) return diff --git a/internal/server/handlers_timeline.go b/internal/server/handlers_timeline.go new file mode 100644 index 000000000..a084d951d --- /dev/null +++ b/internal/server/handlers_timeline.go @@ -0,0 +1,199 @@ +package server + +import ( + "net/http" + "sort" + "strconv" + + "github.com/go-chi/chi/v5" + + "github.com/xarmian/pad/internal/models" +) + +// handleListItemTimeline returns a unified, chronological timeline for an item. +// It merges comments, activities, and versions into a single stream with deduplication. +func (s *Server) handleListItemTimeline(w http.ResponseWriter, r *http.Request) { + workspaceID, ok := s.getWorkspaceID(w, r) + if !ok { + return + } + + itemSlug := chi.URLParam(r, "itemSlug") + item, err := s.store.ResolveItem(workspaceID, itemSlug) + if err != nil || item == nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + limit := 100 + offset := 0 + if v := r.URL.Query().Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + limit = n + } + } + if v := r.URL.Query().Get("offset"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + offset = n + } + } + + // Fetch all three data sources. + comments, err := s.store.ListComments(item.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", err.Error()) + return + } + + // Bulk-load reactions for all comments. + if len(comments) > 0 { + commentIDs := make([]string, len(comments)) + for i, c := range comments { + commentIDs[i] = c.ID + } + reactionsMap, rerr := s.store.ListReactionsByComments(commentIDs) + if rerr == nil && reactionsMap != nil { + for i := range comments { + if reactions, ok := reactionsMap[comments[i].ID]; ok { + comments[i].Reactions = reactions + } + } + } + } + + activities, err := s.store.ListDocumentActivity(item.ID, models.ActivityListParams{Limit: 10000}) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", err.Error()) + return + } + + versions, err := s.store.ListItemVersions(item.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", err.Error()) + return + } + + entries := buildTimeline(comments, activities, versions) + total := len(entries) + + // Apply pagination. + if offset >= len(entries) { + entries = nil + } else { + end := offset + limit + if end > len(entries) { + end = len(entries) + } + entries = entries[offset:end] + } + + writeJSON(w, http.StatusOK, models.TimelineResponse{ + Entries: entries, + Total: total, + }) +} + +// buildTimeline merges comments, activities, and versions into a single chronological +// stream, applying deduplication and collapsing logic. +func buildTimeline(comments []models.Comment, activities []models.Activity, versions []models.Version) []models.TimelineEntry { + // Build a set of version timestamps (rounded to the second) for dedup. + versionTimes := make(map[int64]bool, len(versions)) + for _, v := range versions { + versionTimes[v.CreatedAt.Unix()] = true + } + + // Build a set of activity IDs that are linked to comments (to show as combined cards). + commentActivityIDs := make(map[string]bool) + for _, c := range comments { + if c.ActivityID != "" { + commentActivityIDs[c.ActivityID] = true + } + } + + var entries []models.TimelineEntry + + // Add comment entries (only top-level; replies are nested under parents). + commentsByID := make(map[string]*models.Comment, len(comments)) + for i := range comments { + commentsByID[comments[i].ID] = &comments[i] + } + for i := range comments { + c := comments[i] + // Skip replies — they'll be nested under their parent. + if c.ParentID != "" { + if parent, ok := commentsByID[c.ParentID]; ok { + parent.Replies = append(parent.Replies, c) + } + continue + } + entry := models.TimelineEntry{ + ID: c.ID, + Kind: "comment", + CreatedAt: c.CreatedAt, + Actor: c.CreatedBy, + ActorName: c.Author, + Source: c.Source, + Comment: &comments[i], + } + entries = append(entries, entry) + } + + // Add activity entries (with dedup: skip "updated" if a version exists at same second, + // and skip activities that are linked to a comment since they'll be shown as combined cards). + for i := range activities { + a := activities[i] + + // Skip "read" and "searched" actions — not useful in item timeline. + if a.Action == "read" || a.Action == "searched" { + continue + } + + // Skip activities that already have a linked comment — they're shown via the comment card. + if commentActivityIDs[a.ID] { + continue + } + + // Skip "updated" activities that coincide with a version snapshot. + if a.Action == "updated" && versionTimes[a.CreatedAt.Unix()] { + continue + } + + // Collapse rapid empty-metadata "updated" entries (within 5 min). + if a.Action == "updated" && (a.Metadata == "" || a.Metadata == "{}") { + continue + } + + entry := models.TimelineEntry{ + ID: a.ID, + Kind: "activity", + CreatedAt: a.CreatedAt, + Actor: a.Actor, + ActorName: a.ActorName, + Source: a.Source, + Activity: &activities[i], + } + entries = append(entries, entry) + } + + // Add version entries. + for i := range versions { + v := versions[i] + entry := models.TimelineEntry{ + ID: v.ID, + Kind: "version", + CreatedAt: v.CreatedAt, + Actor: v.CreatedBy, + Source: v.Source, + Version: &versions[i], + } + entries = append(entries, entry) + } + + // Sort chronologically (newest first). + sort.Slice(entries, func(i, j int) bool { + return entries[i].CreatedAt.After(entries[j].CreatedAt) + }) + + return entries +} + diff --git a/internal/server/server.go b/internal/server/server.go index d954f24d4..36aff1a1a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -232,6 +232,7 @@ func (s *Server) setupRouter() { r.Post("/links", s.handleCreateItemLink) r.Get("/comments", s.handleListComments) r.Post("/comments", s.handleCreateComment) + r.Get("/timeline", s.handleListItemTimeline) r.Get("/tasks", s.handleGetItemTasks) }) @@ -239,7 +240,12 @@ func (s *Server) setupRouter() { r.Delete("/links/{linkID}", s.handleDeleteItemLink) // Comments (v2) - r.Delete("/comments/{commentID}", s.handleDeleteComment) + r.Route("/comments/{commentID}", func(r chi.Router) { + r.Delete("/", s.handleDeleteComment) + r.Post("/replies", s.handleCreateReply) + r.Post("/reactions", s.handleAddReaction) + r.Delete("/reactions/{emoji}", s.handleRemoveReaction) + }) // Webhooks r.Route("/webhooks", func(r chi.Router) { diff --git a/internal/store/activities.go b/internal/store/activities.go index c20cce8b5..61273fbf3 100644 --- a/internal/store/activities.go +++ b/internal/store/activities.go @@ -13,7 +13,7 @@ import ( // on the same item by the same user are coalesced into a single activity entry. const ActivityDebounceCooldown = 5 * time.Minute -func (s *Store) CreateActivity(a models.Activity) error { +func (s *Store) CreateActivity(a models.Activity) (string, error) { a.ID = newID() if a.Metadata == "" { a.Metadata = "{}" @@ -24,7 +24,7 @@ func (s *Store) CreateActivity(a models.Activity) error { INSERT INTO activities (id, workspace_id, document_id, action, actor, source, metadata, user_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `, a.ID, a.WorkspaceID, nilIfEmpty(a.DocumentID), a.Action, a.Actor, a.Source, a.Metadata, nilIfEmpty(a.UserID), ts) - return err + return a.ID, err } // CreateActivityDebounced creates a new activity or updates an existing one if a @@ -35,7 +35,7 @@ func (s *Store) CreateActivity(a models.Activity) error { // When merging, the existing activity's timestamp is bumped to now and its // metadata changes are accumulated (so a rapid sequence of field edits produces // a single activity whose metadata lists all changed fields). -func (s *Store) CreateActivityDebounced(a models.Activity) error { +func (s *Store) CreateActivityDebounced(a models.Activity) (string, error) { if a.Metadata == "" { a.Metadata = "{}" } @@ -72,7 +72,7 @@ func (s *Store) CreateActivityDebounced(a models.Activity) error { _, err = s.db.Exec(` UPDATE activities SET metadata = ?, created_at = ? WHERE id = ? `, merged, ts, existingID) - return err + return existingID, err } // mergeActivityMeta combines two activity metadata JSON strings, accumulating diff --git a/internal/store/activities_test.go b/internal/store/activities_test.go index 61a4c7ea5..f8a322da5 100644 --- a/internal/store/activities_test.go +++ b/internal/store/activities_test.go @@ -14,7 +14,7 @@ func TestCreateActivityDebounced_NonUpdateActions(t *testing.T) { doc := createTestDoc(t, s, ws.ID, "Doc", "content") // "created" should always produce a new row, never debounce - err := s.CreateActivityDebounced(models.Activity{ + _, err := s.CreateActivityDebounced(models.Activity{ WorkspaceID: ws.ID, DocumentID: doc.ID, Action: "created", @@ -25,7 +25,7 @@ func TestCreateActivityDebounced_NonUpdateActions(t *testing.T) { t.Fatalf("first CreateActivityDebounced error: %v", err) } - err = s.CreateActivityDebounced(models.Activity{ + _, err = s.CreateActivityDebounced(models.Activity{ WorkspaceID: ws.ID, DocumentID: doc.ID, Action: "created", @@ -48,7 +48,7 @@ func TestCreateActivityDebounced_CoalescesUpdates(t *testing.T) { doc := createTestDoc(t, s, ws.ID, "Doc", "content") // First "updated" activity - err := s.CreateActivityDebounced(models.Activity{ + _, err := s.CreateActivityDebounced(models.Activity{ WorkspaceID: ws.ID, DocumentID: doc.ID, Action: "updated", @@ -61,7 +61,7 @@ func TestCreateActivityDebounced_CoalescesUpdates(t *testing.T) { } // Second "updated" activity within cooldown — should coalesce - err = s.CreateActivityDebounced(models.Activity{ + _, err = s.CreateActivityDebounced(models.Activity{ WorkspaceID: ws.ID, DocumentID: doc.ID, Action: "updated", @@ -105,7 +105,7 @@ func TestCreateActivityDebounced_DifferentUsersDontCoalesce(t *testing.T) { } // First update by user A - err = s.CreateActivityDebounced(models.Activity{ + _, err = s.CreateActivityDebounced(models.Activity{ WorkspaceID: ws.ID, DocumentID: doc.ID, Action: "updated", @@ -118,7 +118,7 @@ func TestCreateActivityDebounced_DifferentUsersDontCoalesce(t *testing.T) { } // Second update by user B — should NOT coalesce - err = s.CreateActivityDebounced(models.Activity{ + _, err = s.CreateActivityDebounced(models.Activity{ WorkspaceID: ws.ID, DocumentID: doc.ID, Action: "updated", @@ -142,7 +142,7 @@ func TestCreateActivityDebounced_DifferentDocsDontCoalesce(t *testing.T) { doc1 := createTestDoc(t, s, ws.ID, "Doc 1", "content 1") doc2 := createTestDoc(t, s, ws.ID, "Doc 2", "content 2") - err := s.CreateActivityDebounced(models.Activity{ + _, err := s.CreateActivityDebounced(models.Activity{ WorkspaceID: ws.ID, DocumentID: doc1.ID, Action: "updated", @@ -153,7 +153,7 @@ func TestCreateActivityDebounced_DifferentDocsDontCoalesce(t *testing.T) { t.Fatalf("doc1 update error: %v", err) } - err = s.CreateActivityDebounced(models.Activity{ + _, err = s.CreateActivityDebounced(models.Activity{ WorkspaceID: ws.ID, DocumentID: doc2.ID, Action: "updated", @@ -178,7 +178,7 @@ func TestCreateActivityDebounced_TimestampBumped(t *testing.T) { doc := createTestDoc(t, s, ws.ID, "Doc", "content") // First update - err := s.CreateActivityDebounced(models.Activity{ + _, err := s.CreateActivityDebounced(models.Activity{ WorkspaceID: ws.ID, DocumentID: doc.ID, Action: "updated", @@ -196,7 +196,7 @@ func TestCreateActivityDebounced_TimestampBumped(t *testing.T) { time.Sleep(1100 * time.Millisecond) // Second update — should bump timestamp - err = s.CreateActivityDebounced(models.Activity{ + _, err = s.CreateActivityDebounced(models.Activity{ WorkspaceID: ws.ID, DocumentID: doc.ID, Action: "updated", @@ -224,7 +224,7 @@ func TestCreateActivityDebounced_MetadataMerge(t *testing.T) { doc := createTestDoc(t, s, ws.ID, "Doc", "content") // Update with no changes metadata - err := s.CreateActivityDebounced(models.Activity{ + _, err := s.CreateActivityDebounced(models.Activity{ WorkspaceID: ws.ID, DocumentID: doc.ID, Action: "updated", @@ -237,7 +237,7 @@ func TestCreateActivityDebounced_MetadataMerge(t *testing.T) { } // Update with changes metadata — should add changes - err = s.CreateActivityDebounced(models.Activity{ + _, err = s.CreateActivityDebounced(models.Activity{ WorkspaceID: ws.ID, DocumentID: doc.ID, Action: "updated", @@ -268,7 +268,7 @@ func TestCreateActivityDebounced_AgentMetaPreserved(t *testing.T) { doc := createTestDoc(t, s, ws.ID, "Doc", "content") // First update with agent metadata - err := s.CreateActivityDebounced(models.Activity{ + _, err := s.CreateActivityDebounced(models.Activity{ WorkspaceID: ws.ID, DocumentID: doc.ID, Action: "updated", @@ -281,7 +281,7 @@ func TestCreateActivityDebounced_AgentMetaPreserved(t *testing.T) { } // Second update with agent metadata - err = s.CreateActivityDebounced(models.Activity{ + _, err = s.CreateActivityDebounced(models.Activity{ WorkspaceID: ws.ID, DocumentID: doc.ID, Action: "updated", @@ -316,7 +316,7 @@ func TestCreateActivityDebounced_MultipleRapidSaves(t *testing.T) { // Simulate 10 rapid autosaves for i := 0; i < 10; i++ { - err := s.CreateActivityDebounced(models.Activity{ + _, err := s.CreateActivityDebounced(models.Activity{ WorkspaceID: ws.ID, DocumentID: doc.ID, Action: "updated", @@ -340,7 +340,7 @@ func TestCreateActivityDebounced_NoUserID(t *testing.T) { doc := createTestDoc(t, s, ws.ID, "Doc", "content") // Two updates with no user ID (pre-auth mode) - err := s.CreateActivityDebounced(models.Activity{ + _, err := s.CreateActivityDebounced(models.Activity{ WorkspaceID: ws.ID, DocumentID: doc.ID, Action: "updated", @@ -352,7 +352,7 @@ func TestCreateActivityDebounced_NoUserID(t *testing.T) { t.Fatalf("first update error: %v", err) } - err = s.CreateActivityDebounced(models.Activity{ + _, err = s.CreateActivityDebounced(models.Activity{ WorkspaceID: ws.ID, DocumentID: doc.ID, Action: "updated", diff --git a/internal/store/comments.go b/internal/store/comments.go index 19d2e9080..231bb896e 100644 --- a/internal/store/comments.go +++ b/internal/store/comments.go @@ -26,9 +26,10 @@ func (s *Store) CreateComment(workspaceID, itemID string, input models.CommentCr } _, err := s.db.Exec(` - INSERT INTO comments (id, item_id, workspace_id, author, body, created_by, source, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - id, itemID, workspaceID, author, input.Body, createdBy, source, ts, ts, + INSERT INTO comments (id, item_id, workspace_id, author, body, created_by, source, activity_id, parent_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, itemID, workspaceID, author, input.Body, createdBy, source, + nilIfEmpty(input.ActivityID), nilIfEmpty(input.ParentID), ts, ts, ) if err != nil { return nil, fmt.Errorf("insert comment: %w", err) @@ -41,7 +42,8 @@ func (s *Store) CreateComment(workspaceID, itemID string, input models.CommentCr func (s *Store) GetComment(id string) (*models.Comment, error) { row := s.db.QueryRow(` SELECT c.id, c.item_id, c.workspace_id, c.author, c.body, - c.created_by, c.source, c.created_at, c.updated_at, + c.created_by, c.source, COALESCE(c.activity_id, ''), COALESCE(c.parent_id, ''), + c.created_at, c.updated_at, i.title, i.slug FROM comments c JOIN items i ON i.id = c.item_id @@ -51,7 +53,8 @@ func (s *Store) GetComment(id string) (*models.Comment, error) { var createdAt, updatedAt string err := row.Scan( &c.ID, &c.ItemID, &c.WorkspaceID, &c.Author, &c.Body, - &c.CreatedBy, &c.Source, &createdAt, &updatedAt, + &c.CreatedBy, &c.Source, &c.ActivityID, &c.ParentID, + &createdAt, &updatedAt, &c.ItemTitle, &c.ItemSlug, ) if err == sql.ErrNoRows { @@ -69,7 +72,8 @@ func (s *Store) GetComment(id string) (*models.Comment, error) { func (s *Store) ListComments(itemID string) ([]models.Comment, error) { rows, err := s.db.Query(` SELECT c.id, c.item_id, c.workspace_id, c.author, c.body, - c.created_by, c.source, c.created_at, c.updated_at + c.created_by, c.source, COALESCE(c.activity_id, ''), COALESCE(c.parent_id, ''), + c.created_at, c.updated_at FROM comments c WHERE c.item_id = ? ORDER BY c.created_at ASC`, itemID) @@ -84,7 +88,8 @@ func (s *Store) ListComments(itemID string) ([]models.Comment, error) { var createdAt, updatedAt string if err := rows.Scan( &c.ID, &c.ItemID, &c.WorkspaceID, &c.Author, &c.Body, - &c.CreatedBy, &c.Source, &createdAt, &updatedAt, + &c.CreatedBy, &c.Source, &c.ActivityID, &c.ParentID, + &createdAt, &updatedAt, ); err != nil { return nil, fmt.Errorf("scan comment: %w", err) } diff --git a/internal/store/migrations/016_timeline.sql b/internal/store/migrations/016_timeline.sql new file mode 100644 index 000000000..c723f616c --- /dev/null +++ b/internal/store/migrations/016_timeline.sql @@ -0,0 +1,23 @@ +-- Timeline: link comments to activities, threading, and reactions + +-- Link comments to the activity that triggered them (e.g. status-change comments) +ALTER TABLE comments ADD COLUMN activity_id TEXT REFERENCES activities(id); + +-- Threading: allow comments to be replies to other comments +ALTER TABLE comments ADD COLUMN parent_id TEXT REFERENCES comments(id); + +CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_id); +CREATE INDEX IF NOT EXISTS idx_comments_activity ON comments(activity_id); + +-- Emoji reactions on comments +CREATE TABLE IF NOT EXISTS comment_reactions ( + id TEXT PRIMARY KEY, + comment_id TEXT NOT NULL REFERENCES comments(id) ON DELETE CASCADE, + user_id TEXT REFERENCES users(id), + actor TEXT NOT NULL DEFAULT 'user', + emoji TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(comment_id, user_id, emoji) +); + +CREATE INDEX IF NOT EXISTS idx_comment_reactions_comment ON comment_reactions(comment_id); diff --git a/internal/store/reactions.go b/internal/store/reactions.go new file mode 100644 index 000000000..f8e8c9d52 --- /dev/null +++ b/internal/store/reactions.go @@ -0,0 +1,104 @@ +package store + +import ( + "fmt" + + "github.com/xarmian/pad/internal/models" +) + +// AddReaction adds an emoji reaction to a comment. Returns the created reaction, +// or the existing one if the same user+emoji already exists. +func (s *Store) AddReaction(commentID, userID, actor, emoji string) (*models.Reaction, error) { + id := newID() + ts := now() + + // Store empty string (not NULL) for anonymous users so the UNIQUE constraint + // on (comment_id, user_id, emoji) works correctly — SQLite treats NULL != NULL. + _, err := s.db.Exec(` + INSERT INTO comment_reactions (id, comment_id, user_id, actor, emoji, created_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(comment_id, user_id, emoji) DO NOTHING`, + id, commentID, userID, actor, emoji, ts, + ) + if err != nil { + return nil, fmt.Errorf("add reaction: %w", err) + } + + // Return the reaction (may be existing if ON CONFLICT hit). + return s.getReaction(commentID, userID, emoji) +} + +func (s *Store) getReaction(commentID, userID, emoji string) (*models.Reaction, error) { + var r models.Reaction + var createdAt string + err := s.db.QueryRow(` + SELECT id, comment_id, COALESCE(user_id, ''), actor, emoji, created_at + FROM comment_reactions + WHERE comment_id = ? AND user_id = ? AND emoji = ?`, + commentID, userID, emoji, + ).Scan(&r.ID, &r.CommentID, &r.UserID, &r.Actor, &r.Emoji, &createdAt) + if err != nil { + return nil, fmt.Errorf("get reaction: %w", err) + } + r.CreatedAt = parseTime(createdAt) + return &r, nil +} + +// RemoveReaction removes a specific emoji reaction by a user from a comment. +func (s *Store) RemoveReaction(commentID, userID, emoji string) error { + result, err := s.db.Exec(` + DELETE FROM comment_reactions + WHERE comment_id = ? AND user_id = ? AND emoji = ?`, + commentID, userID, emoji, + ) + if err != nil { + return fmt.Errorf("remove reaction: %w", err) + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("reaction not found") + } + return nil +} + +// ListReactionsByComments loads all reactions for a set of comment IDs, keyed by comment ID. +func (s *Store) ListReactionsByComments(commentIDs []string) (map[string][]models.Reaction, error) { + if len(commentIDs) == 0 { + return nil, nil + } + + // Build query with placeholders. + query := ` + SELECT cr.id, cr.comment_id, COALESCE(cr.user_id, ''), cr.actor, cr.emoji, cr.created_at, + COALESCE(u.name, '') as actor_name + FROM comment_reactions cr + LEFT JOIN users u ON u.id = cr.user_id + WHERE cr.comment_id IN (` + args := make([]interface{}, len(commentIDs)) + for i, id := range commentIDs { + if i > 0 { + query += ", " + } + query += "?" + args[i] = id + } + query += `) ORDER BY cr.created_at ASC` + + rows, err := s.db.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("list reactions: %w", err) + } + defer rows.Close() + + result := make(map[string][]models.Reaction) + for rows.Next() { + var r models.Reaction + var createdAt string + if err := rows.Scan(&r.ID, &r.CommentID, &r.UserID, &r.Actor, &r.Emoji, &createdAt, &r.ActorName); err != nil { + return nil, fmt.Errorf("scan reaction: %w", err) + } + r.CreatedAt = parseTime(createdAt) + result[r.CommentID] = append(result[r.CommentID], r) + } + return result, rows.Err() +} diff --git a/internal/store/store.go b/internal/store/store.go index 1ce01478b..ae287e631 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -81,6 +81,7 @@ func (s *Store) migrate() error { "013_workspace_invitations.sql", "014_platform_settings.sql", "015_password_resets.sql", + "016_timeline.sql", } for _, name := range migrations { diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 084ba1f2e..a1815cf06 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -576,14 +576,14 @@ func TestActivity(t *testing.T) { ws := createTestWorkspace(t, s, "Test") doc := createTestDoc(t, s, ws.ID, "Doc", "content") - s.CreateActivity(models.Activity{ + _, _ = s.CreateActivity(models.Activity{ WorkspaceID: ws.ID, DocumentID: doc.ID, Action: "created", Actor: "user", Source: "web", }) - s.CreateActivity(models.Activity{ + _, _ = s.CreateActivity(models.Activity{ WorkspaceID: ws.ID, DocumentID: doc.ID, Action: "updated", diff --git a/skills/pad/SKILL.md b/skills/pad/SKILL.md index f5a3bd6be..46ff2c8ca 100644 --- a/skills/pad/SKILL.md +++ b/skills/pad/SKILL.md @@ -56,9 +56,11 @@ Interpret the user's intent and route to the appropriate action. Here are common - "find anything about OAuth" → `pad item search "OAuth" --format json` **Updating:** -- "I finished the OAuth fix" / "mark TASK-5 as done" → `pad item update TASK-5 --status done` -- "I'm starting on TASK-3" → `pad item update TASK-3 --status in-progress` -- "deprioritize IDEA-7" → `pad item update IDEA-7 --priority low` +- "I finished the OAuth fix" / "mark TASK-5 as done" → `pad item update TASK-5 --status done --comment "OAuth redirect fix verified and deployed"` +- "I'm starting on TASK-3" → `pad item update TASK-3 --status in-progress --comment "Beginning implementation"` +- "deprioritize IDEA-7" → `pad item update IDEA-7 --priority low --comment "Deprioritized per team discussion"` + +**Best practice:** Always use `--comment` when changing status to explain *why*. This creates an audit trail linking each status change to a reason. **Planning:** - "let's plan the next phase" → Multi-step planning workflow (see below) @@ -140,11 +142,16 @@ pad item list --all # everything across all collections # Show item detail — use the issue ID (e.g. TASK-5, BUG-8) pad item show TASK-5 [--format json|markdown] -# Update items — use the issue ID -pad item update TASK-5 --status done +# Update items — use the issue ID (--comment adds an audit note) +pad item update TASK-5 --status done --comment "Fixed login bug, tests passing" pad item update IDEA-3 --priority high --assignee dave pad item update DOC-1 --stdin < updated-doc.md +# Comments — add notes, reply to threads +pad item comment TASK-5 "Investigated the race condition, root cause is in mutex handler" +pad item comment TASK-5 "Good catch, fixed in commit abc123" --reply-to +pad item comments TASK-5 # List all comments + # Delete (archive) — use the issue ID pad item delete TASK-5 @@ -272,14 +279,15 @@ All commands support `--format json` (for parsing) or `--format table` (default, ## Key Principles 1. **Use issue IDs, not slugs.** Every item has an ID like `TASK-5` or `BUG-8`. Use these in all commands: `pad item show TASK-5`, `pad item update BUG-8 --status done`. The CLI prints issue IDs in all output — look for them. -2. **Discuss before acting.** Always show what you plan to create/modify and get confirmation. -3. **Use the CLI.** Every action goes through `pad` commands — don't try to modify the database directly. -4. **Be conversational.** You're not a command executor. You're a project partner. -5. **Reference existing items.** Use `[[Item Title]]` links in content to connect items. -6. **Keep it practical.** Tasks should be PR-sized. Ideas should be actionable. Docs should be concise. -7. **Attribution matters.** Items you create will have `created_by: agent` and `source: cli` automatically. -8. **Follow project conventions.** Always load and follow active conventions before performing work. They are project-specific rules that override your defaults. -9. **Learn and teach.** When the user corrects your behavior or teaches you a project-specific rule, offer to save it as a convention: "Should I save this as a project convention so future agents follow it too?" Use `pad item create convention "Title" --field trigger= --field scope= --field priority=should --stdin` with an appropriate trigger inferred from the context. +2. **Always comment on status changes.** When marking a task done, in-progress, or blocked, use `--comment` to explain why: `pad item update TASK-5 --status done --comment "Fixed and verified"`. This builds an audit trail that helps the whole team. +3. **Discuss before acting.** Always show what you plan to create/modify and get confirmation. +4. **Use the CLI.** Every action goes through `pad` commands — don't try to modify the database directly. +5. **Be conversational.** You're not a command executor. You're a project partner. +6. **Reference existing items.** Use `[[Item Title]]` links in content to connect items. +7. **Keep it practical.** Tasks should be PR-sized. Ideas should be actionable. Docs should be concise. +8. **Attribution matters.** Items you create will have `created_by: agent` and `source: cli` automatically. +9. **Follow project conventions.** Always load and follow active conventions before performing work. They are project-specific rules that override your defaults. +10. **Learn and teach.** When the user corrects your behavior or teaches you a project-specific rule, offer to save it as a convention: "Should I save this as a project convention so future agents follow it too?" Use `pad item create convention "Title" --field trigger= --field scope= --field priority=should --stdin` with an appropriate trigger inferred from the context. ## Anything Else diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index d95cecd3b..14ef49e16 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -26,7 +26,9 @@ import type { User, UserProfileUpdate, APIToken, - APITokenWithSecret + APITokenWithSecret, + Reaction, + TimelineResponse } from '$lib/types'; const BASE = '/api/v1'; @@ -254,9 +256,38 @@ export const api = { delete: (ws: string, commentId: string) => request(`/workspaces/${ws}/comments/${commentId}`, { method: 'DELETE' + }), + + reply: (ws: string, commentId: string, data: CommentCreate) => + request(`/workspaces/${ws}/comments/${commentId}/replies`, { + method: 'POST', + body: JSON.stringify(data) + }), + + addReaction: (ws: string, commentId: string, emoji: string) => + request(`/workspaces/${ws}/comments/${commentId}/reactions`, { + method: 'POST', + body: JSON.stringify({ emoji }) + }), + + removeReaction: (ws: string, commentId: string, emoji: string) => + request(`/workspaces/${ws}/comments/${commentId}/reactions/${encodeURIComponent(emoji)}`, { + method: 'DELETE' }) }, + // ── Timeline ────────────────────────────────────────────────────────────── + + timeline: { + list: (ws: string, itemSlug: string, params?: { limit?: number; offset?: number }) => { + const qs = new URLSearchParams(); + if (params?.limit != null) qs.set('limit', String(params.limit)); + if (params?.offset != null) qs.set('offset', String(params.offset)); + const suffix = qs.toString() ? `?${qs}` : ''; + return request(`/workspaces/${ws}/items/${itemSlug}/timeline${suffix}`); + } + }, + // ── Views ───────────────────────────────────────────────────────────────── views: { diff --git a/web/src/lib/components/timeline/ItemTimeline.svelte b/web/src/lib/components/timeline/ItemTimeline.svelte new file mode 100644 index 000000000..d46e86d06 --- /dev/null +++ b/web/src/lib/components/timeline/ItemTimeline.svelte @@ -0,0 +1,425 @@ + + +
+
+

Timeline

+ {#if total > 0} + {total} + {/if} +
+ + +
+ +
+ Ctrl+Enter to submit + +
+
+ + {#if loading && entries.length === 0} +
+ + Loading timeline... +
+ {/if} + + {#if error} +
{error}
+ {/if} + + {#if !loading || entries.length > 0} +
+ {#each entries as entry (entry.id)} +
+
+ + +
+
+ {#if entry.kind === 'comment' && entry.comment} + + {:else if entry.kind === 'activity' && entry.activity} + + {:else if entry.kind === 'version' && entry.version} + + {/if} +
+
+ {/each} + + {#if entries.length === 0 && !loading} +
No timeline entries yet.
+ {/if} +
+ {/if} +
+ + diff --git a/web/src/lib/components/timeline/ReactionPicker.svelte b/web/src/lib/components/timeline/ReactionPicker.svelte new file mode 100644 index 000000000..3c43c4212 --- /dev/null +++ b/web/src/lib/components/timeline/ReactionPicker.svelte @@ -0,0 +1,132 @@ + + +
+ + + {#if open} +
+
+ {#each EMOJIS as emoji (emoji)} + + {/each} +
+
+ {/if} +
+ + diff --git a/web/src/lib/components/timeline/TimelineActivityCard.svelte b/web/src/lib/components/timeline/TimelineActivityCard.svelte new file mode 100644 index 000000000..ad61ecbbc --- /dev/null +++ b/web/src/lib/components/timeline/TimelineActivityCard.svelte @@ -0,0 +1,218 @@ + + +
+
+ {getActorLabel(activity)} + {#if activity.actor_name} + {activity.actor_name} + {/if} + {getActionLabel(activity.action)} + {#if activity.action === 'moved' && metadata.from_collection && metadata.to_collection} + + {metadata.from_collection} → {metadata.to_collection} + + {/if} + {getSourceLabel(activity.source)} + + {relativeTime(activity.created_at)} +
+ {#if changes.length > 0} +
+ {#each changes as change (change.field)} + + {change.field}: + {change.from} + → + {change.to} + + {/each} +
+ {/if} +
+ + diff --git a/web/src/lib/components/timeline/TimelineCommentCard.svelte b/web/src/lib/components/timeline/TimelineCommentCard.svelte new file mode 100644 index 000000000..88c494360 --- /dev/null +++ b/web/src/lib/components/timeline/TimelineCommentCard.svelte @@ -0,0 +1,529 @@ + + +
+
+ {getActorLabel(comment.created_by)} + {#if comment.author} + {comment.author} + {/if} + {getSourceLabel(comment.source)} + + {relativeTime(comment.created_at)} + +
+ + {#if comment.activity_id} +
commented on update
+ {/if} + +
+ {@html renderMarkdown(comment.body, items, wsSlug)} +
+ + + + {#if showReplyForm} +
+ +
+ Ctrl+Enter to submit · Esc to cancel +
+ + +
+
+
+ {/if} + + {#if comment.replies && comment.replies.length > 0} +
+ {#each comment.replies as reply (reply.id)} + {@const replyReactionGroups = groupReactions(reply.reactions)} +
+
+ {getActorLabel(reply.created_by)} + {#if reply.author} + {reply.author} + {/if} + + {relativeTime(reply.created_at)} + +
+ +
+ {@html renderMarkdown(reply.body, items, wsSlug)} +
+ +
+ {#each replyReactionGroups as group (group.emoji)} + + {/each} + handleAddReplyReaction(reply, emoji)} /> +
+
+ {/each} +
+ {/if} +
+ + diff --git a/web/src/lib/components/timeline/TimelineVersionCard.svelte b/web/src/lib/components/timeline/TimelineVersionCard.svelte new file mode 100644 index 000000000..585d27a67 --- /dev/null +++ b/web/src/lib/components/timeline/TimelineVersionCard.svelte @@ -0,0 +1,330 @@ + + +
+ + + {#if expanded} +
+
+ +
+ +
+ {#if confirming} +
+ Restore to this version? +
+ + +
+
+ {:else} + + {/if} +
+
+ {/if} +
+ + diff --git a/web/src/lib/services/sse.svelte.ts b/web/src/lib/services/sse.svelte.ts index e17b4bd1d..1b14189f1 100644 --- a/web/src/lib/services/sse.svelte.ts +++ b/web/src/lib/services/sse.svelte.ts @@ -23,7 +23,9 @@ const ITEM_EVENTS = [ 'item_restored', 'workspace_updated', 'comment_created', - 'comment_deleted' + 'comment_deleted', + 'reaction_added', + 'reaction_removed' ] as const; function createSSEService() { diff --git a/web/src/lib/types/index.ts b/web/src/lib/types/index.ts index d8daf8ed4..845bde3be 100644 --- a/web/src/lib/types/index.ts +++ b/web/src/lib/types/index.ts @@ -298,6 +298,7 @@ export interface ItemUpdate { parent_id?: string; last_modified_by?: string; source?: string; + comment?: string; } // ─── Versions ──────────────────────────────────────────────────────────────── @@ -351,10 +352,14 @@ export interface Comment { body: string; created_by: string; source: string; + activity_id?: string; + parent_id?: string; created_at: string; updated_at: string; item_title?: string; item_slug?: string; + replies?: Comment[]; + reactions?: Reaction[]; } export interface CommentCreate { @@ -362,6 +367,36 @@ export interface CommentCreate { body: string; created_by?: string; source?: string; + parent_id?: string; +} + +export interface Reaction { + id: string; + comment_id: string; + user_id?: string; + actor: string; + emoji: string; + created_at: string; + actor_name?: string; +} + +// ─── Timeline ──────────────────────────────────────────────────────────────── + +export interface TimelineEntry { + id: string; + kind: 'comment' | 'activity' | 'version'; + created_at: string; + actor: string; + actor_name?: string; + source: string; + comment?: Comment; + activity?: Activity; + version?: Version; +} + +export interface TimelineResponse { + entries: TimelineEntry[]; + total: number; } // ─── Views ─────────────────────────────────────────────────────────────────── diff --git a/web/src/routes/[workspace]/[collection]/[slug]/+page.svelte b/web/src/routes/[workspace]/[collection]/[slug]/+page.svelte index 365421f8c..867db7d6d 100644 --- a/web/src/routes/[workspace]/[collection]/[slug]/+page.svelte +++ b/web/src/routes/[workspace]/[collection]/[slug]/+page.svelte @@ -10,13 +10,12 @@ import RawMarkdownEditor from '$lib/components/editor/RawMarkdownEditor.svelte'; import type { Editor as EditorType } from '@tiptap/core'; import FieldEditor from '$lib/components/fields/FieldEditor.svelte'; - import VersionHistory from '$lib/components/versions/VersionHistory.svelte'; - import CommentThread from '$lib/components/comments/CommentThread.svelte'; + import ItemTimeline from '$lib/components/timeline/ItemTimeline.svelte'; import PhaseTasks from '$lib/components/phases/PhaseTasks.svelte'; import { goto } from '$app/navigation'; import { relativeTime, wikiLinksToMarkdown, markdownToWikiLinks, cleanBrokenLinks } from '$lib/utils/markdown'; import { toastStore } from '$lib/stores/toast.svelte'; - import type { Item, Collection, CollectionSettings, QuickAction, ItemLink, ItemRelationRef, ItemImplementationNote, ItemDecisionLogEntry } from '$lib/types'; + import type { Item, Collection, CollectionSettings, QuickAction, ItemLink, ItemRelationRef } from '$lib/types'; import { parseFields, parseSchema, parseSettings, formatItemRef } from '$lib/types'; import QuickActionsMenu from '$lib/components/common/QuickActionsMenu.svelte'; @@ -68,7 +67,6 @@ let contentDebounceTimer: ReturnType | undefined; let saveStatus = $state<'idle' | 'saving' | 'saved'>('idle'); let saveStatusTimer: ReturnType | undefined; - let showHistory = $state(false); let confirmDelete = $state(false); let deleting = $state(false); let rawMode = $state(false); @@ -78,15 +76,6 @@ let relationshipGroups = $derived(item ? buildRelationshipGroups(item, itemLinks) : []); let closureEntries = $derived(item?.derived_closure?.related_items?.map((related) => relationRefEntry(related)) ?? []); let codeContext = $derived(item?.code_context ?? null); - let implementationNotes = $derived(item?.implementation_notes ?? []); - let decisionLog = $derived(item?.decision_log ?? []); - let noteSummary = $state(''); - let noteDetails = $state(''); - let decisionTitle = $state(''); - let decisionRationale = $state(''); - let savingNote = $state(false); - let savingDecision = $state(false); - $effect(() => { if (wsSlug && collSlug && itemSlug) { loadData(); @@ -226,86 +215,6 @@ } } - function buildStructuredEntryID(prefix: string): string { - if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { - return `${prefix}-${crypto.randomUUID()}`; - } - return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; - } - - async function updateStructuredFields(updatedFields: Record) { - if (!item) return; - saveStatus = 'saving'; - item = await api.items.update(wsSlug, item.id, { fields: JSON.stringify(updatedFields) }); - showSaved(); - } - - async function addImplementationNote() { - if (!item || savingNote) return; - const summary = noteSummary.trim(); - const details = noteDetails.trim(); - if (!summary) return; - - savingNote = true; - try { - const updatedFields = { - ...fields, - implementation_notes: [ - ...(implementationNotes ?? []), - { - id: buildStructuredEntryID('note'), - summary, - details: details || undefined, - created_at: new Date().toISOString(), - created_by: 'web' - } satisfies ItemImplementationNote - ] - }; - await updateStructuredFields(updatedFields); - noteSummary = ''; - noteDetails = ''; - toastStore.show('Implementation note added', 'success'); - } catch { - saveStatus = 'idle'; - toastStore.show('Failed to add implementation note', 'error'); - } finally { - savingNote = false; - } - } - - async function addDecisionLogEntry() { - if (!item || savingDecision) return; - const decision = decisionTitle.trim(); - const rationale = decisionRationale.trim(); - if (!decision) return; - - savingDecision = true; - try { - const updatedFields = { - ...fields, - decision_log: [ - ...(decisionLog ?? []), - { - id: buildStructuredEntryID('decision'), - decision, - rationale: rationale || undefined, - created_at: new Date().toISOString(), - created_by: 'web' - } satisfies ItemDecisionLogEntry - ] - }; - await updateStructuredFields(updatedFields); - decisionTitle = ''; - decisionRationale = ''; - toastStore.show('Decision log entry added', 'success'); - } catch { - saveStatus = 'idle'; - toastStore.show('Failed to add decision log entry', 'error'); - } finally { - savingDecision = false; - } - } - function handleContentUpdate(markdown: string) { clearTimeout(contentDebounceTimer); saveStatus = 'saving'; @@ -458,7 +367,6 @@ function handleVersionRestore(updatedItem: Item) { item = updatedItem; - showHistory = false; } async function handleDelete() { @@ -546,10 +454,9 @@ {/if}
{/if} -
-
-
-
-

Implementation Notes

- {implementationNotes.length} -
-
- { - if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') addImplementationNote(); - }} - /> - - -
- {#if implementationNotes.length > 0} -
- {#each implementationNotes as note (note.id ?? `${note.summary}-${note.created_at ?? ''}`)} -
-
{note.summary}
- - {#if note.details} -

{note.details}

- {/if} -
- {/each} -
- {:else} -

No implementation notes yet.

- {/if} -
- -
-
-

Decision Log

- {decisionLog.length} -
-
- { - if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') addDecisionLogEntry(); - }} - /> - - -
- {#if decisionLog.length > 0} -
- {#each decisionLog as decision (decision.id ?? `${decision.decision}-${decision.created_at ?? ''}`)} -
-
{decision.decision}
- - {#if decision.rationale} -

{decision.rationale}

- {/if} -
- {/each} -
- {:else} -

No decisions recorded yet.

- {/if} -
-
-
- {#if collSlug === 'phases' && item} {/if} - -
- + +
+
- - - {#if showHistory} - - {/if} {/if}