mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 06:19:49 +00:00
feat: unified item timeline with comment-on-update, threading, and reactions (#54)
* feat: unified item timeline with comment-on-update, threading, and reactions (IDEA-115) Replace the separate Comments section and Version History modal on the item detail page with a single chronological timeline that interleaves comments, activities, and content versions. Key changes: - Add --comment flag to `pad item update` so agents/users can explain status changes inline (creates a comment linked to the activity) - Add threaded replies (parent_id on comments) with inline reply UI - Add emoji reactions on comments (new comment_reactions table) - New /timeline API endpoint merges comments, activities, and versions server-side with dedup and collapsing of rapid edits - New Svelte timeline components: ItemTimeline, TimelineCommentCard, TimelineActivityCard, TimelineVersionCard, ReactionPicker - Remove Implementation Notes and Decision Log inputs from web UI - Update skill docs to encourage --comment on status changes * fix: address review findings for PR #54 (iteration 1) - Use ListItemVersions instead of ListVersions in timeline endpoint so item content history renders correctly - Add workspace validation to reply and reaction handlers to prevent cross-workspace comment mutation - Raise activity cap from 500 to 10000 to avoid silently truncating long timelines - Fix toggleReaction to always POST (idempotent) instead of incorrectly matching other users' reactions for DELETE - Await onReply promise before clearing draft to prevent duplicate submissions and lost drafts on failure - Register reaction_added/reaction_removed in SSE ITEM_EVENTS so reactions from other sessions appear in real-time Co-Authored-By: Claude <noreply@anthropic.com> * fix: address review findings for PR #54 (iteration 2) - Fix nil pointer dereference in timeline handler when item not found - Store empty string instead of NULL for reaction user_id so UNIQUE constraint works correctly in SQLite - Add workspace validation to handleDeleteComment (cross-workspace deletion was possible) - Fix SKILL.md duplicate numbering (4. appeared twice) - Remove || true debug artifacts from reaction conditionals - Replace SvelteMap with plain Map in non-reactive groupReactions - Use != null checks for timeline API params to handle offset=0 - Log warning on comment creation failure during item update Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
+14
-2
@@ -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 <ref> <message>",
|
||||
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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
+21
-13
@@ -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 <comment-id>
|
||||
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=<inferred> --field scope=<inferred> --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=<inferred> --field scope=<inferred> --field priority=should --stdin` with an appropriate trigger inferred from the context.
|
||||
|
||||
## Anything Else
|
||||
|
||||
|
||||
@@ -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<void>(`/workspaces/${ws}/comments/${commentId}`, {
|
||||
method: 'DELETE'
|
||||
}),
|
||||
|
||||
reply: (ws: string, commentId: string, data: CommentCreate) =>
|
||||
request<Comment>(`/workspaces/${ws}/comments/${commentId}/replies`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
}),
|
||||
|
||||
addReaction: (ws: string, commentId: string, emoji: string) =>
|
||||
request<Reaction>(`/workspaces/${ws}/comments/${commentId}/reactions`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ emoji })
|
||||
}),
|
||||
|
||||
removeReaction: (ws: string, commentId: string, emoji: string) =>
|
||||
request<void>(`/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<TimelineResponse>(`/workspaces/${ws}/items/${itemSlug}/timeline${suffix}`);
|
||||
}
|
||||
},
|
||||
|
||||
// ── Views ─────────────────────────────────────────────────────────────────
|
||||
|
||||
views: {
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { api } from '$lib/api/client';
|
||||
import { sseService } from '$lib/services/sse.svelte';
|
||||
import type { TimelineEntry, TimelineResponse, Item } from '$lib/types';
|
||||
import TimelineCommentCard from './TimelineCommentCard.svelte';
|
||||
import TimelineActivityCard from './TimelineActivityCard.svelte';
|
||||
import TimelineVersionCard from './TimelineVersionCard.svelte';
|
||||
|
||||
interface Props {
|
||||
wsSlug: string;
|
||||
itemSlug: string;
|
||||
currentContent: string;
|
||||
items?: Item[];
|
||||
onRestore?: (item: Item) => void;
|
||||
}
|
||||
|
||||
let { wsSlug, itemSlug, currentContent, items = [], onRestore }: Props = $props();
|
||||
|
||||
let entries: TimelineEntry[] = $state([]);
|
||||
let total: number = $state(0);
|
||||
let loading: boolean = $state(false);
|
||||
let error: string = $state('');
|
||||
let newBody: string = $state('');
|
||||
|
||||
async function loadTimeline() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const resp: TimelineResponse = await api.timeline.list(wsSlug, itemSlug);
|
||||
entries = resp.entries;
|
||||
total = resp.total;
|
||||
} catch (err: any) {
|
||||
error = err?.message ?? 'Failed to load timeline';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void wsSlug;
|
||||
void itemSlug;
|
||||
loadTimeline();
|
||||
});
|
||||
|
||||
const relevantEvents = new Set([
|
||||
'item_updated',
|
||||
'comment_created',
|
||||
'comment_deleted',
|
||||
'reaction_added',
|
||||
'reaction_removed'
|
||||
]);
|
||||
|
||||
const unsubscribe = sseService.onItemEvent((event) => {
|
||||
if (relevantEvents.has(event.type)) {
|
||||
loadTimeline();
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
let submitting: boolean = $state(false);
|
||||
|
||||
async function submitComment() {
|
||||
if (!newBody.trim() || submitting) return;
|
||||
submitting = true;
|
||||
try {
|
||||
await api.comments.create(wsSlug, itemSlug, {
|
||||
body: newBody.trim(),
|
||||
created_by: 'user',
|
||||
source: 'web'
|
||||
});
|
||||
newBody = '';
|
||||
await loadTimeline();
|
||||
} catch (err: any) {
|
||||
error = err?.message ?? 'Failed to post comment';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
submitComment();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReply(commentId: string, body: string) {
|
||||
try {
|
||||
await api.comments.reply(wsSlug, commentId, {
|
||||
body,
|
||||
created_by: 'user',
|
||||
source: 'web'
|
||||
});
|
||||
await loadTimeline();
|
||||
} catch (err: any) {
|
||||
error = err?.message ?? 'Failed to post reply';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(commentId: string) {
|
||||
if (!confirm('Delete this comment?')) return;
|
||||
try {
|
||||
await api.comments.delete(wsSlug, commentId);
|
||||
await loadTimeline();
|
||||
} catch (err: any) {
|
||||
error = err?.message ?? 'Failed to delete comment';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReaction(commentId: string, emoji: string) {
|
||||
try {
|
||||
await api.comments.addReaction(wsSlug, commentId, emoji);
|
||||
await loadTimeline();
|
||||
} catch (err: any) {
|
||||
error = err?.message ?? 'Failed to add reaction';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveReaction(commentId: string, emoji: string) {
|
||||
try {
|
||||
await api.comments.removeReaction(wsSlug, commentId, emoji);
|
||||
await loadTimeline();
|
||||
} catch (err: any) {
|
||||
error = err?.message ?? 'Failed to remove reaction';
|
||||
}
|
||||
}
|
||||
|
||||
function dotClass(kind: TimelineEntry['kind']): string {
|
||||
if (kind === 'comment') return 'dot-comment';
|
||||
if (kind === 'version') return 'dot-version';
|
||||
return 'dot-activity';
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="timeline">
|
||||
<header class="timeline-header">
|
||||
<h3 class="timeline-title">Timeline</h3>
|
||||
{#if total > 0}
|
||||
<span class="entry-count">{total}</span>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<!-- Comment compose -->
|
||||
<div class="compose">
|
||||
<textarea
|
||||
class="compose-input"
|
||||
placeholder="Write a comment..."
|
||||
bind:value={newBody}
|
||||
onkeydown={handleKeydown}
|
||||
disabled={submitting}
|
||||
></textarea>
|
||||
<div class="compose-actions">
|
||||
<span class="shortcut-hint">Ctrl+Enter to submit</span>
|
||||
<button
|
||||
class="submit-btn"
|
||||
type="button"
|
||||
disabled={!newBody.trim() || submitting}
|
||||
onclick={submitComment}
|
||||
>
|
||||
{submitting ? 'Posting...' : 'Comment'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading && entries.length === 0}
|
||||
<div class="loading">
|
||||
<span class="spinner"></span>
|
||||
<span class="loading-text">Loading timeline...</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if !loading || entries.length > 0}
|
||||
<div class="entry-list">
|
||||
{#each entries as entry (entry.id)}
|
||||
<div class="entry">
|
||||
<div class="entry-rail">
|
||||
<span class="dot {dotClass(entry.kind)}"></span>
|
||||
<span class="line"></span>
|
||||
</div>
|
||||
<div class="entry-content">
|
||||
{#if entry.kind === 'comment' && entry.comment}
|
||||
<TimelineCommentCard
|
||||
comment={entry.comment}
|
||||
{wsSlug}
|
||||
{items}
|
||||
onDelete={handleDelete}
|
||||
onReply={handleReply}
|
||||
onReaction={handleReaction}
|
||||
onRemoveReaction={handleRemoveReaction}
|
||||
/>
|
||||
{:else if entry.kind === 'activity' && entry.activity}
|
||||
<TimelineActivityCard activity={entry.activity} />
|
||||
{:else if entry.kind === 'version' && entry.version}
|
||||
<TimelineVersionCard
|
||||
version={entry.version}
|
||||
{wsSlug}
|
||||
{itemSlug}
|
||||
{currentContent}
|
||||
{onRestore}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if entries.length === 0 && !loading}
|
||||
<div class="empty">No timeline entries yet.</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.timeline-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.timeline-title {
|
||||
margin: 0;
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.entry-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.5em;
|
||||
padding: 0 var(--space-1);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75em;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ── Compose ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.compose {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.compose-input {
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9em;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.compose-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.compose-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.compose-input:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.compose-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.shortcut-hint {
|
||||
font-size: 0.75em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: var(--space-1) var(--space-4);
|
||||
background: var(--accent-blue);
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
color: #fff;
|
||||
font-size: 0.85em;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Loading / Error ──────────────────────────────────────────────────── */
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4);
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent-blue);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: color-mix(in srgb, var(--accent-red, #ef4444) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent-red, #ef4444) 30%, transparent);
|
||||
border-radius: var(--radius);
|
||||
color: var(--accent-red, #ef4444);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* ── Timeline entries ─────────────────────────────────────────────────── */
|
||||
|
||||
.entry-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.entry-rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
padding-top: var(--space-2);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.dot-comment {
|
||||
background: var(--accent-blue);
|
||||
}
|
||||
|
||||
.dot-activity {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.dot-version {
|
||||
background: var(--accent-green);
|
||||
}
|
||||
|
||||
.line {
|
||||
width: 1px;
|
||||
flex: 1;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.entry:last-child .line {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.entry-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: var(--space-6);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,132 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
onSelect: (emoji: string) => void;
|
||||
}
|
||||
|
||||
const EMOJIS = ['👍', '👎', '❤️', '🎉', '🚀', '👀', '💯', '🤔', '✅', '❌', '🔥', '💡'];
|
||||
|
||||
let { onSelect }: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let pickerEl: HTMLDivElement | undefined = $state();
|
||||
|
||||
function toggle() {
|
||||
open = !open;
|
||||
}
|
||||
|
||||
function select(emoji: string) {
|
||||
open = false;
|
||||
onSelect(emoji);
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (pickerEl && !pickerEl.contains(event.target as Node)) {
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
document.addEventListener('click', handleClickOutside, true);
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClickOutside, true);
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="reaction-picker" bind:this={pickerEl}>
|
||||
<button class="trigger" type="button" onclick={toggle} title="Add reaction">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5" />
|
||||
<circle cx="5.5" cy="6.5" r="1" fill="currentColor" />
|
||||
<circle cx="10.5" cy="6.5" r="1" fill="currentColor" />
|
||||
<path d="M5 10c.5 1.5 2 2.5 3 2.5s2.5-1 3-2.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
|
||||
</svg>
|
||||
<span class="plus">+</span>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<div class="popover">
|
||||
<div class="emoji-grid">
|
||||
{#each EMOJIS as emoji (emoji)}
|
||||
<button
|
||||
class="emoji-btn"
|
||||
type="button"
|
||||
onclick={() => select(emoji)}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.reaction-picker {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: var(--space-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.trigger:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.plus {
|
||||
font-size: 0.75em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.popover {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
margin-bottom: var(--space-1);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-2);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.emoji-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.emoji-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 1.2em;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.emoji-btn:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,218 @@
|
||||
<script lang="ts">
|
||||
import type { Activity } from '$lib/types';
|
||||
import { relativeTime } from '$lib/utils/markdown';
|
||||
|
||||
let { activity }: { activity: Activity } = $props();
|
||||
|
||||
function parseMetadata(meta: string): Record<string, any> {
|
||||
try {
|
||||
return JSON.parse(meta);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
interface Change {
|
||||
field: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
function parseChanges(changesStr: string): Change[] {
|
||||
if (!changesStr) return [];
|
||||
return changesStr.split(';').map((part) => {
|
||||
const trimmed = part.trim();
|
||||
const colonIdx = trimmed.indexOf(':');
|
||||
if (colonIdx === -1) return null;
|
||||
const field = trimmed.slice(0, colonIdx).trim();
|
||||
const valuePart = trimmed.slice(colonIdx + 1).trim();
|
||||
const arrowParts = valuePart.split('\u2192');
|
||||
if (arrowParts.length === 2) {
|
||||
return { field, from: arrowParts[0].trim(), to: arrowParts[1].trim() };
|
||||
}
|
||||
return null;
|
||||
}).filter((c): c is Change => c !== null);
|
||||
}
|
||||
|
||||
const metadata = $derived(parseMetadata(activity.metadata));
|
||||
const changes = $derived(parseChanges(metadata.changes ?? ''));
|
||||
|
||||
const actionLabels: Record<string, string> = {
|
||||
created: 'created',
|
||||
updated: 'updated',
|
||||
archived: 'archived',
|
||||
restored: 'restored',
|
||||
moved: 'moved',
|
||||
commented: 'commented'
|
||||
};
|
||||
|
||||
function getActionLabel(action: string): string {
|
||||
return actionLabels[action] ?? action;
|
||||
}
|
||||
|
||||
function getActionClass(action: string): string {
|
||||
if (action === 'created') return 'action-created';
|
||||
if (action === 'archived') return 'action-archived';
|
||||
return '';
|
||||
}
|
||||
|
||||
function getActorBadgeClass(actor: string): string {
|
||||
return actor === 'agent' ? 'actor-agent' : 'actor-user';
|
||||
}
|
||||
|
||||
function getActorLabel(a: Activity): string {
|
||||
return a.actor === 'agent' ? 'Agent' : 'User';
|
||||
}
|
||||
|
||||
function getSourceLabel(source: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
cli: 'CLI',
|
||||
web: 'Web',
|
||||
skill: 'Skill'
|
||||
};
|
||||
return labels[source] ?? source;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<span class="badge {getActorBadgeClass(activity.actor)}">{getActorLabel(activity)}</span>
|
||||
{#if activity.actor_name}
|
||||
<span class="actor-name">{activity.actor_name}</span>
|
||||
{/if}
|
||||
<span class="action-label {getActionClass(activity.action)}">{getActionLabel(activity.action)}</span>
|
||||
{#if activity.action === 'moved' && metadata.from_collection && metadata.to_collection}
|
||||
<span class="move-detail">
|
||||
{metadata.from_collection} → {metadata.to_collection}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="badge source-badge">{getSourceLabel(activity.source)}</span>
|
||||
<span class="spacer"></span>
|
||||
<span class="timestamp" title={new Date(activity.created_at).toLocaleString()}>{relativeTime(activity.created_at)}</span>
|
||||
</div>
|
||||
{#if changes.length > 0}
|
||||
<div class="changes">
|
||||
{#each changes as change (change.field)}
|
||||
<span class="change-pill">
|
||||
<span class="change-field">{change.field}:</span>
|
||||
<span class="change-from">{change.from}</span>
|
||||
<span class="change-arrow">→</span>
|
||||
<span class="change-to">{change.to}</span>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.75em;
|
||||
font-weight: 600;
|
||||
line-height: 1.75;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.actor-agent {
|
||||
background: color-mix(in srgb, var(--accent-purple) 15%, transparent);
|
||||
color: var(--accent-purple);
|
||||
}
|
||||
|
||||
.actor-user {
|
||||
background: color-mix(in srgb, var(--accent-blue) 15%, transparent);
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.actor-name {
|
||||
font-size: 0.85em;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.action-label {
|
||||
font-size: 0.85em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.action-label.action-created {
|
||||
color: var(--accent-green);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.action-label.action-archived {
|
||||
color: var(--accent-red);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.move-detail {
|
||||
font-size: 0.8em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.source-badge {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
font-size: 0.8em;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.changes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
padding-left: var(--space-1);
|
||||
}
|
||||
|
||||
.change-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3em;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.75em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.change-field {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.change-arrow {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.change-to {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,529 @@
|
||||
<script lang="ts">
|
||||
import type { Comment, Item, Reaction } from '$lib/types';
|
||||
import { relativeTime, renderMarkdown } from '$lib/utils/markdown';
|
||||
import ReactionPicker from './ReactionPicker.svelte';
|
||||
|
||||
interface Props {
|
||||
comment: Comment;
|
||||
wsSlug: string;
|
||||
items: Item[];
|
||||
onDelete: (commentId: string) => void;
|
||||
onReply: (commentId: string, body: string) => void | Promise<void>;
|
||||
onReaction: (commentId: string, emoji: string) => void;
|
||||
onRemoveReaction: (commentId: string, emoji: string) => void;
|
||||
}
|
||||
|
||||
let { comment, wsSlug, items, onDelete, onReply, onReaction, onRemoveReaction }: Props = $props();
|
||||
|
||||
let showReplyForm = $state(false);
|
||||
let replyBody = $state('');
|
||||
let submittingReply = $state(false);
|
||||
|
||||
async function submitReply() {
|
||||
const body = replyBody.trim();
|
||||
if (!body || submittingReply) return;
|
||||
submittingReply = true;
|
||||
try {
|
||||
await onReply(comment.id, body);
|
||||
replyBody = '';
|
||||
showReplyForm = false;
|
||||
} catch {
|
||||
// Keep draft on failure so the user can retry.
|
||||
} finally {
|
||||
submittingReply = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleReplyKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
submitReply();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
showReplyForm = false;
|
||||
replyBody = '';
|
||||
}
|
||||
}
|
||||
|
||||
interface ReactionGroup {
|
||||
emoji: string;
|
||||
count: number;
|
||||
actors: string[];
|
||||
}
|
||||
|
||||
function groupReactions(reactions: Reaction[] | undefined): ReactionGroup[] {
|
||||
if (!reactions || reactions.length === 0) return [];
|
||||
const map = new Map<string, ReactionGroup>();
|
||||
for (const r of reactions) {
|
||||
const existing = map.get(r.emoji);
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
existing.actors.push(r.actor_name ?? r.actor);
|
||||
} else {
|
||||
map.set(r.emoji, { emoji: r.emoji, count: 1, actors: [r.actor_name ?? r.actor] });
|
||||
}
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
function getActorLabel(createdBy: string): string {
|
||||
return createdBy === 'agent' ? 'Agent' : 'User';
|
||||
}
|
||||
|
||||
function getActorBadgeClass(createdBy: string): string {
|
||||
return createdBy === 'agent' ? 'author-agent' : 'author-user';
|
||||
}
|
||||
|
||||
function getBorderClass(createdBy: string): string {
|
||||
return createdBy === 'agent' ? 'border-agent' : 'border-user';
|
||||
}
|
||||
|
||||
function getSourceLabel(source: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
cli: 'CLI',
|
||||
web: 'Web',
|
||||
skill: 'Skill'
|
||||
};
|
||||
return labels[source] ?? source;
|
||||
}
|
||||
|
||||
function toggleReaction(emoji: string) {
|
||||
// Always POST — the server uses ON CONFLICT DO NOTHING if the current user
|
||||
// already reacted with this emoji. A dedicated "remove my reaction" button
|
||||
// or a second click handled server-side is needed for proper toggle, but
|
||||
// for now adding is safe and idempotent.
|
||||
onReaction(comment.id, emoji);
|
||||
}
|
||||
|
||||
function handleAddReaction(emoji: string) {
|
||||
onReaction(comment.id, emoji);
|
||||
}
|
||||
|
||||
function toggleReplyReaction(reply: Comment, emoji: string) {
|
||||
onReaction(reply.id, emoji);
|
||||
}
|
||||
|
||||
function handleAddReplyReaction(reply: Comment, emoji: string) {
|
||||
onReaction(reply.id, emoji);
|
||||
}
|
||||
|
||||
const reactionGroups = $derived(groupReactions(comment.reactions));
|
||||
</script>
|
||||
|
||||
<div class="comment-card {getBorderClass(comment.created_by)}">
|
||||
<div class="comment-header">
|
||||
<span class="author-badge {getActorBadgeClass(comment.created_by)}">{getActorLabel(comment.created_by)}</span>
|
||||
{#if comment.author}
|
||||
<span class="author-name">{comment.author}</span>
|
||||
{/if}
|
||||
<span class="source-badge">{getSourceLabel(comment.source)}</span>
|
||||
<span class="spacer"></span>
|
||||
<span class="timestamp" title={new Date(comment.created_at).toLocaleString()}>{relativeTime(comment.created_at)}</span>
|
||||
<button
|
||||
class="delete-btn"
|
||||
type="button"
|
||||
onclick={() => onDelete(comment.id)}
|
||||
title="Delete comment"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if comment.activity_id}
|
||||
<div class="activity-label">commented on update</div>
|
||||
{/if}
|
||||
|
||||
<div class="comment-body markdown-body">
|
||||
{@html renderMarkdown(comment.body, items, wsSlug)}
|
||||
</div>
|
||||
|
||||
<div class="comment-footer">
|
||||
<div class="reactions-row">
|
||||
{#each reactionGroups as group (group.emoji)}
|
||||
<button
|
||||
class="reaction-chip"
|
||||
type="button"
|
||||
title={group.actors.join(', ')}
|
||||
onclick={() => toggleReaction(group.emoji)}
|
||||
>
|
||||
<span class="reaction-emoji">{group.emoji}</span>
|
||||
<span class="reaction-count">{group.count}</span>
|
||||
</button>
|
||||
{/each}
|
||||
<ReactionPicker onSelect={handleAddReaction} />
|
||||
</div>
|
||||
<button class="reply-btn" type="button" onclick={() => { showReplyForm = !showReplyForm; }}>
|
||||
Reply
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showReplyForm}
|
||||
<div class="reply-compose">
|
||||
<textarea
|
||||
class="reply-input"
|
||||
placeholder="Write a reply..."
|
||||
bind:value={replyBody}
|
||||
onkeydown={handleReplyKeydown}
|
||||
disabled={submittingReply}
|
||||
></textarea>
|
||||
<div class="reply-actions">
|
||||
<span class="reply-hint">Ctrl+Enter to submit · Esc to cancel</span>
|
||||
<div class="reply-buttons">
|
||||
<button class="reply-cancel" type="button" onclick={() => { showReplyForm = false; replyBody = ''; }}>Cancel</button>
|
||||
<button class="reply-submit" type="button" disabled={!replyBody.trim() || submittingReply} onclick={submitReply}>
|
||||
{submittingReply ? 'Posting...' : 'Reply'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if comment.replies && comment.replies.length > 0}
|
||||
<div class="replies">
|
||||
{#each comment.replies as reply (reply.id)}
|
||||
{@const replyReactionGroups = groupReactions(reply.reactions)}
|
||||
<div class="reply-card {getBorderClass(reply.created_by)}">
|
||||
<div class="reply-header">
|
||||
<span class="author-badge {getActorBadgeClass(reply.created_by)}">{getActorLabel(reply.created_by)}</span>
|
||||
{#if reply.author}
|
||||
<span class="author-name">{reply.author}</span>
|
||||
{/if}
|
||||
<span class="spacer"></span>
|
||||
<span class="timestamp" title={new Date(reply.created_at).toLocaleString()}>{relativeTime(reply.created_at)}</span>
|
||||
<button
|
||||
class="delete-btn"
|
||||
type="button"
|
||||
onclick={() => onDelete(reply.id)}
|
||||
title="Delete reply"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="reply-body markdown-body">
|
||||
{@html renderMarkdown(reply.body, items, wsSlug)}
|
||||
</div>
|
||||
|
||||
<div class="reactions-row">
|
||||
{#each replyReactionGroups as group (group.emoji)}
|
||||
<button
|
||||
class="reaction-chip"
|
||||
type="button"
|
||||
title={group.actors.join(', ')}
|
||||
onclick={() => toggleReplyReaction(reply, group.emoji)}
|
||||
>
|
||||
<span class="reaction-emoji">{group.emoji}</span>
|
||||
<span class="reaction-count">{group.count}</span>
|
||||
</button>
|
||||
{/each}
|
||||
<ReactionPicker onSelect={(emoji) => handleAddReplyReaction(reply, emoji)} />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.comment-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
border-left: 3px solid var(--accent-blue);
|
||||
}
|
||||
|
||||
.comment-card.border-agent {
|
||||
border-left-color: var(--accent-purple, #a855f7);
|
||||
}
|
||||
|
||||
.comment-card.border-user {
|
||||
border-left-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.comment-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.author-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px var(--space-2);
|
||||
border-radius: 9999px;
|
||||
font-size: 0.7em;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.author-user {
|
||||
background: color-mix(in srgb, var(--accent-blue) 15%, transparent);
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.author-agent {
|
||||
background: color-mix(in srgb, var(--accent-purple, #a855f7) 15%, transparent);
|
||||
color: var(--accent-purple, #a855f7);
|
||||
}
|
||||
|
||||
.author-name {
|
||||
font-size: 0.85em;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.source-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.75em;
|
||||
font-weight: 600;
|
||||
line-height: 1.75;
|
||||
white-space: nowrap;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
font-size: 0.8em;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-1);
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.comment-card:hover .comment-header > .delete-btn,
|
||||
.reply-card:hover .reply-header > .delete-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
color: var(--accent-red);
|
||||
background: color-mix(in srgb, var(--accent-red) 10%, transparent);
|
||||
}
|
||||
|
||||
.activity-label {
|
||||
font-size: 0.75em;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.comment-body,
|
||||
.reply-body {
|
||||
font-size: 0.9em;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.comment-body :global(p:last-child),
|
||||
.reply-body :global(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.comment-body :global(p:first-child),
|
||||
.reply-body :global(p:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.comment-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.reactions-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.reaction-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25em;
|
||||
padding: 2px var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 9999px;
|
||||
background: var(--bg-tertiary);
|
||||
cursor: pointer;
|
||||
font-size: 0.8em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.reaction-chip:hover {
|
||||
border-color: var(--accent-blue);
|
||||
background: color-mix(in srgb, var(--accent-blue) 10%, transparent);
|
||||
}
|
||||
|
||||
.reaction-emoji {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.reaction-count {
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.reply-btn {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.8em;
|
||||
padding: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.reply-btn:hover {
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.reply-compose {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.reply-input {
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.85em;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
min-height: 52px;
|
||||
}
|
||||
|
||||
.reply-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.reply-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.reply-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.reply-hint {
|
||||
font-size: 0.7em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.reply-buttons {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.reply-cancel {
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reply-cancel:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.reply-submit {
|
||||
padding: var(--space-1) var(--space-3);
|
||||
background: var(--accent-blue);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: #fff;
|
||||
font-size: 0.8em;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reply-submit:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.reply-submit:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.replies {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
margin-left: var(--space-3);
|
||||
padding-left: var(--space-3);
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.reply-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
border-left: 2px solid var(--accent-blue);
|
||||
}
|
||||
|
||||
.reply-card.border-agent {
|
||||
border-left-color: var(--accent-purple, #a855f7);
|
||||
}
|
||||
|
||||
.reply-card.border-user {
|
||||
border-left-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.reply-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,330 @@
|
||||
<script lang="ts">
|
||||
import type { Version, Item } from '$lib/types';
|
||||
import { api } from '$lib/api/client';
|
||||
import DiffView from '$lib/components/versions/DiffView.svelte';
|
||||
import { relativeTime } from '$lib/utils/markdown';
|
||||
|
||||
interface Props {
|
||||
version: Version;
|
||||
wsSlug: string;
|
||||
itemSlug: string;
|
||||
currentContent: string;
|
||||
onRestore?: (item: Item) => void;
|
||||
}
|
||||
|
||||
let { version, wsSlug, itemSlug, currentContent, onRestore }: Props = $props();
|
||||
|
||||
let expanded = $state(false);
|
||||
let confirming = $state(false);
|
||||
let restoring = $state(false);
|
||||
|
||||
function toggle() {
|
||||
expanded = !expanded;
|
||||
if (!expanded) {
|
||||
confirming = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startRestore() {
|
||||
confirming = true;
|
||||
}
|
||||
|
||||
function cancelRestore() {
|
||||
confirming = false;
|
||||
}
|
||||
|
||||
async function confirmRestore() {
|
||||
restoring = true;
|
||||
try {
|
||||
const updatedItem = await api.versions.restore(wsSlug, itemSlug, version.id);
|
||||
confirming = false;
|
||||
onRestore?.(updatedItem);
|
||||
} finally {
|
||||
restoring = false;
|
||||
}
|
||||
}
|
||||
|
||||
function actorLabel(actor: string): string {
|
||||
return actor === 'agent' ? 'Agent' : 'User';
|
||||
}
|
||||
|
||||
function sourceLabel(source: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
cli: 'CLI',
|
||||
web: 'Web',
|
||||
skill: 'Skill'
|
||||
};
|
||||
return labels[source] ?? source;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="version-card" class:expanded>
|
||||
<button class="card-header" type="button" onclick={toggle}>
|
||||
<span class="icon">📄</span>
|
||||
<div class="header-content">
|
||||
<span class="label">Content updated</span>
|
||||
{#if version.change_summary}
|
||||
<span class="change-summary">{version.change_summary}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="badges">
|
||||
<span
|
||||
class="badge"
|
||||
class:badge-agent={version.created_by === 'agent'}
|
||||
class:badge-user={version.created_by !== 'agent'}
|
||||
>
|
||||
{actorLabel(version.created_by)}
|
||||
</span>
|
||||
<span class="badge badge-source">
|
||||
{sourceLabel(version.source)}
|
||||
</span>
|
||||
</div>
|
||||
<span class="timestamp" title={new Date(version.created_at).toLocaleString()}>
|
||||
{relativeTime(version.created_at)}
|
||||
</span>
|
||||
<span class="chevron" class:open={expanded}>▸</span>
|
||||
</button>
|
||||
|
||||
{#if expanded}
|
||||
<div class="card-body">
|
||||
<div class="diff-container">
|
||||
<DiffView oldContent={version.content} newContent={currentContent} />
|
||||
</div>
|
||||
|
||||
<div class="restore-area">
|
||||
{#if confirming}
|
||||
<div class="confirm-prompt">
|
||||
<span class="confirm-text">Restore to this version?</span>
|
||||
<div class="confirm-actions">
|
||||
<button
|
||||
class="btn-cancel"
|
||||
type="button"
|
||||
onclick={cancelRestore}
|
||||
disabled={restoring}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="btn-restore-confirm"
|
||||
type="button"
|
||||
onclick={confirmRestore}
|
||||
disabled={restoring}
|
||||
>
|
||||
{restoring ? 'Restoring...' : 'Confirm Restore'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
class="btn-restore"
|
||||
type="button"
|
||||
onclick={startRestore}
|
||||
>
|
||||
Restore this version
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.version-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-secondary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.version-card.expanded {
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.card-header:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 1em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.85em;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.change-summary {
|
||||
font-size: 0.8em;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px var(--space-2);
|
||||
border-radius: 9999px;
|
||||
font-size: 0.7em;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.badge-agent {
|
||||
background: color-mix(in srgb, var(--accent-purple, #a855f7) 15%, transparent);
|
||||
color: var(--accent-purple, #a855f7);
|
||||
}
|
||||
|
||||
.badge-user {
|
||||
background: color-mix(in srgb, var(--accent-blue) 15%, transparent);
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.badge-source {
|
||||
background: color-mix(in srgb, var(--accent-green) 15%, transparent);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
font-size: 0.8em;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
font-size: 0.75em;
|
||||
color: var(--text-muted);
|
||||
transition: transform 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chevron.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: var(--space-3);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.diff-container {
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.restore-area {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn-restore {
|
||||
padding: var(--space-1) var(--space-3);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-restore:hover {
|
||||
background: color-mix(in srgb, var(--accent-blue) 10%, transparent);
|
||||
border-color: var(--accent-blue);
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.confirm-prompt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: color-mix(in srgb, var(--accent-yellow, #eab308) 8%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent-yellow, #eab308) 30%, transparent);
|
||||
border-radius: var(--radius);
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.confirm-text {
|
||||
font-size: 0.8em;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
padding: var(--space-1) var(--space-3);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-cancel:hover:not(:disabled) {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-restore-confirm {
|
||||
padding: var(--space-1) var(--space-3);
|
||||
background: var(--accent-blue);
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
color: #fff;
|
||||
font-size: 0.8em;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-restore-confirm:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.btn-restore-confirm:disabled,
|
||||
.btn-cancel:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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<typeof setTimeout> | undefined;
|
||||
let saveStatus = $state<'idle' | 'saving' | 'saved'>('idle');
|
||||
let saveStatusTimer: ReturnType<typeof setTimeout> | 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<string, any>) {
|
||||
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}
|
||||
<button
|
||||
class="action-btn"
|
||||
class:active={showHistory}
|
||||
onclick={() => { showHistory = !showHistory; }}
|
||||
onclick={() => { document.getElementById('item-timeline')?.scrollIntoView({ behavior: 'smooth' }); }}
|
||||
>
|
||||
History
|
||||
Timeline
|
||||
</button>
|
||||
<div class="move-wrapper">
|
||||
<button class="action-btn" onclick={() => { showMoveMenu = !showMoveMenu; }} disabled={moving}>
|
||||
@@ -747,137 +654,23 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="structured-notes-section">
|
||||
<div class="structured-notes-grid">
|
||||
<section class="structured-card">
|
||||
<div class="structured-card-header">
|
||||
<h3 class="section-title">Implementation Notes</h3>
|
||||
<span class="structured-count">{implementationNotes.length}</span>
|
||||
</div>
|
||||
<div class="structured-entry-form">
|
||||
<input
|
||||
bind:value={noteSummary}
|
||||
class="structured-input"
|
||||
type="text"
|
||||
placeholder="What changed or mattered?"
|
||||
onkeydown={(e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') addImplementationNote();
|
||||
}}
|
||||
/>
|
||||
<textarea
|
||||
bind:value={noteDetails}
|
||||
class="structured-textarea"
|
||||
rows="3"
|
||||
placeholder="Optional details, tradeoffs, or follow-up context"
|
||||
></textarea>
|
||||
<button class="structured-submit" onclick={addImplementationNote} disabled={savingNote || !noteSummary.trim()}>
|
||||
{savingNote ? 'Saving…' : 'Add note'}
|
||||
</button>
|
||||
</div>
|
||||
{#if implementationNotes.length > 0}
|
||||
<div class="structured-entry-list">
|
||||
{#each implementationNotes as note (note.id ?? `${note.summary}-${note.created_at ?? ''}`)}
|
||||
<article class="structured-entry">
|
||||
<div class="structured-entry-title">{note.summary}</div>
|
||||
<div class="structured-entry-meta">
|
||||
{#if note.created_at}
|
||||
<span>{relativeTime(note.created_at)}</span>
|
||||
{/if}
|
||||
{#if note.created_by}
|
||||
<span>{note.created_by}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if note.details}
|
||||
<p class="structured-entry-body">{note.details}</p>
|
||||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="structured-empty">No implementation notes yet.</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="structured-card">
|
||||
<div class="structured-card-header">
|
||||
<h3 class="section-title">Decision Log</h3>
|
||||
<span class="structured-count">{decisionLog.length}</span>
|
||||
</div>
|
||||
<div class="structured-entry-form">
|
||||
<input
|
||||
bind:value={decisionTitle}
|
||||
class="structured-input"
|
||||
type="text"
|
||||
placeholder="What decision did we make?"
|
||||
onkeydown={(e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') addDecisionLogEntry();
|
||||
}}
|
||||
/>
|
||||
<textarea
|
||||
bind:value={decisionRationale}
|
||||
class="structured-textarea"
|
||||
rows="3"
|
||||
placeholder="Optional rationale, tradeoffs, or alternatives considered"
|
||||
></textarea>
|
||||
<button class="structured-submit" onclick={addDecisionLogEntry} disabled={savingDecision || !decisionTitle.trim()}>
|
||||
{savingDecision ? 'Saving…' : 'Add decision'}
|
||||
</button>
|
||||
</div>
|
||||
{#if decisionLog.length > 0}
|
||||
<div class="structured-entry-list">
|
||||
{#each decisionLog as decision (decision.id ?? `${decision.decision}-${decision.created_at ?? ''}`)}
|
||||
<article class="structured-entry">
|
||||
<div class="structured-entry-title">{decision.decision}</div>
|
||||
<div class="structured-entry-meta">
|
||||
{#if decision.created_at}
|
||||
<span>{relativeTime(decision.created_at)}</span>
|
||||
{/if}
|
||||
{#if decision.created_by}
|
||||
<span>{decision.created_by}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if decision.rationale}
|
||||
<p class="structured-entry-body">{decision.rationale}</p>
|
||||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="structured-empty">No decisions recorded yet.</p>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phase Tasks (shown only for phases collection) -->
|
||||
{#if collSlug === 'phases' && item}
|
||||
<PhaseTasks {wsSlug} {itemSlug} itemId={item.id} phaseFields={fields} onTasksChange={handlePhaseTasksChange} />
|
||||
{/if}
|
||||
|
||||
<!-- Comments -->
|
||||
<div class="comments-section">
|
||||
<CommentThread {wsSlug} {itemSlug} items={collectionStore.items ?? []} />
|
||||
<!-- Unified Timeline (comments + activity + versions) -->
|
||||
<div id="item-timeline" class="timeline-section">
|
||||
<ItemTimeline
|
||||
{wsSlug}
|
||||
{itemSlug}
|
||||
currentContent={item.content ?? ''}
|
||||
items={collectionStore.items ?? []}
|
||||
onRestore={handleVersionRestore}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Version History Modal -->
|
||||
{#if showHistory}
|
||||
<div class="modal-backdrop" role="button" tabindex="0"
|
||||
onclick={() => { showHistory = false; }}
|
||||
onkeydown={(e) => { if (e.key === 'Escape' || e.key === 'Enter') { showHistory = false; } }}>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div class="modal-container" role="none" onclick={(e) => e.stopPropagation()}>
|
||||
<VersionHistory
|
||||
{wsSlug}
|
||||
{itemSlug}
|
||||
currentContent={item.content ?? ''}
|
||||
onRestore={handleVersionRestore}
|
||||
onClose={() => { showHistory = false; }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@@ -1315,115 +1108,8 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Structured notes */
|
||||
.structured-notes-section {
|
||||
margin-top: var(--space-6);
|
||||
padding-top: var(--space-6);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.structured-notes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.structured-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.structured-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.structured-card .section-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.structured-count {
|
||||
font-size: 0.75em;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-tertiary);
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.structured-entry-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.structured-input,
|
||||
.structured-textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font: inherit;
|
||||
}
|
||||
.structured-textarea {
|
||||
resize: vertical;
|
||||
min-height: 88px;
|
||||
}
|
||||
.structured-submit {
|
||||
align-self: flex-start;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--accent-blue);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.9em;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.structured-submit:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.structured-entry-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.structured-entry {
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
|
||||
}
|
||||
.structured-entry:first-child {
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
.structured-entry-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.structured-entry-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
margin-top: 4px;
|
||||
font-size: 0.78em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.structured-entry-body {
|
||||
margin: var(--space-2) 0 0;
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.structured-empty {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Comments */
|
||||
.comments-section {
|
||||
/* Timeline */
|
||||
.timeline-section {
|
||||
margin-top: var(--space-6);
|
||||
padding-top: var(--space-6);
|
||||
border-top: 1px solid var(--border);
|
||||
@@ -1532,35 +1218,6 @@
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.modal-container {
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.modal-container :global(.version-panel) {
|
||||
max-height: 85vh;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.modal-backdrop {
|
||||
padding: var(--space-2);
|
||||
}
|
||||
.modal-container {
|
||||
max-height: 92vh;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.layout-balanced .fields-panel {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
Reference in New Issue
Block a user