@@ -873,7 +873,32 @@ func (m Model) renderChat() string {
873873 }
874874 header := agentNameSt .Render ("[" + msg .From + "]" ) + " " + mutedSt .Render (title ) + " " + ts
875875 body := resultBoxSt .Render (msg .Content )
876- sb .WriteString (" " + header + "\n " + body + "\n \n " )
876+ sb .WriteString (" " + header + "\n " + body + "\n " )
877+ // Render reactions if any.
878+ if len (msg .Reactions ) > 0 {
879+ reactionLine := " " + mutedSt .Render (" reactions: " )
880+ for _ , r := range msg .Reactions {
881+ var rst lipgloss.Style
882+ switch r .Reaction {
883+ case "approve" :
884+ rst = lipgloss .NewStyle ().Foreground (cGreen ).Bold (true )
885+ case "reject" :
886+ rst = lipgloss .NewStyle ().Foreground (cRed ).Bold (true )
887+ default :
888+ rst = lipgloss .NewStyle ().Foreground (cMuted )
889+ }
890+ reactionLine += rst .Render (r .Reactor + ":" + r .Reaction ) + mutedSt .Render (" " )
891+ }
892+ sb .WriteString (reactionLine + "\n " )
893+ }
894+ sb .WriteString ("\n " )
895+ continue
896+ }
897+
898+ // Handoff event — render as dim system line.
899+ if msg .IsAgent && msg .Kind == "handoff" {
900+ label := agentDmSt .Render ("[" + msg .From + " → " + msg .Meta + "]" )
901+ sb .WriteString (fmt .Sprintf (" %s %s %s\n " , ts , label , mutedSt .Render (msg .Content )))
877902 continue
878903 }
879904
@@ -911,6 +936,11 @@ func (m Model) renderChat() string {
911936 nameSt = memberNameSt
912937 }
913938 }
939+ // Thread reply reference.
940+ if msg .ReplyTo != "" {
941+ replyRef := "↩ " + msg .ReplyFrom
942+ sb .WriteString (fmt .Sprintf (" %s\n " , agentDmSt .Render (" " + replyRef )))
943+ }
914944 content := renderMentions (msg .Content , m .username )
915945 line := fmt .Sprintf (" %s %s %s" , ts , nameSt .Render (msg .From + ":" ), content )
916946 sb .WriteString (line + "\n " )
@@ -1087,16 +1117,46 @@ func (m Model) renderTaskRow(t *protocol.Task, cw int) string {
10871117 }
10881118 var metaPlain []string
10891119 if t .DueDate != "" {
1090- metaPlain = append (metaPlain , "due: " + t .DueDate )
1120+ today := time .Now ().Format ("2006-01-02" )
1121+ dueStr := "due: " + t .DueDate
1122+ if t .Status != protocol .StatusDone {
1123+ if t .DueDate < today {
1124+ dueStr = "OVERDUE: " + t .DueDate
1125+ } else if t .DueDate == today {
1126+ dueStr = "DUE TODAY: " + t .DueDate
1127+ }
1128+ }
1129+ metaPlain = append (metaPlain , dueStr )
10911130 }
10921131 if t .UpdatedBy != "" && t .UpdatedBy != t .Assignee {
10931132 metaPlain = append (metaPlain , "by: " + t .UpdatedBy )
10941133 }
1134+ if len (t .Comments ) > 0 {
1135+ metaPlain = append (metaPlain , fmt .Sprintf ("%d comment(s)" , len (t .Comments )))
1136+ }
1137+ if len (t .Dependencies ) > 0 {
1138+ metaPlain = append (metaPlain , fmt .Sprintf ("deps: %d" , len (t .Dependencies )))
1139+ }
10951140 hasMeta := len (metaPlain ) > 0 || t .ClaimedBy != ""
10961141 if hasMeta {
10971142 line := indent
10981143 if len (metaPlain ) > 0 {
1099- line += mutedSt .Render (strings .Join (metaPlain , " " ))
1144+ // Color due-date entries red/yellow when overdue/due-today.
1145+ rendered := make ([]string , len (metaPlain ))
1146+ today := time .Now ().Format ("2006-01-02" )
1147+ for i , s := range metaPlain {
1148+ switch {
1149+ case strings .HasPrefix (s , "OVERDUE" ):
1150+ rendered [i ] = lipgloss .NewStyle ().Foreground (cRed ).Bold (true ).Render (s )
1151+ case strings .HasPrefix (s , "DUE TODAY" ):
1152+ rendered [i ] = lipgloss .NewStyle ().Foreground (cYellow ).Bold (true ).Render (s )
1153+ case strings .HasPrefix (s , "due:" ) && t .DueDate > today :
1154+ rendered [i ] = mutedSt .Render (s )
1155+ default :
1156+ rendered [i ] = mutedSt .Render (s )
1157+ }
1158+ }
1159+ line += strings .Join (rendered , mutedSt .Render (" " ))
11001160 }
11011161 if t .ClaimedBy != "" {
11021162 if len (metaPlain ) > 0 {
@@ -1605,6 +1665,30 @@ func (m Model) viewStatus() string {
16051665 conn = lipgloss .NewStyle ().Foreground (cRed ).Bold (true ).Render ("* OFFLINE " )
16061666 }
16071667 left := conn + statusBarSt .Render (m .statusMsg )
1668+
1669+ // Due-date reminders: scan tasks relevant to this user.
1670+ today := time .Now ().Format ("2006-01-02" )
1671+ var overdue , dueToday int
1672+ for _ , t := range m .tasks {
1673+ if t .Status == protocol .StatusDone || t .DueDate == "" {
1674+ continue
1675+ }
1676+ if m .role != "admin" && t .Assignee != m .username {
1677+ continue
1678+ }
1679+ if t .DueDate < today {
1680+ overdue ++
1681+ } else if t .DueDate == today {
1682+ dueToday ++
1683+ }
1684+ }
1685+ if overdue > 0 {
1686+ left += " " + lipgloss .NewStyle ().Foreground (cRed ).Bold (true ).Render (fmt .Sprintf ("! %d overdue" , overdue ))
1687+ }
1688+ if dueToday > 0 {
1689+ left += " " + lipgloss .NewStyle ().Foreground (cYellow ).Bold (true ).Render (fmt .Sprintf ("~ %d due today" , dueToday ))
1690+ }
1691+
16081692 ver := mutedSt .Render (version .Version )
16091693 gap := m .width - lipgloss .Width (left ) - lipgloss .Width (ver )
16101694 if gap < 1 {
@@ -1804,26 +1888,29 @@ func (m *Model) handleCommand(text string) tea.Cmd {
18041888 case "/help" :
18051889 lines := []string {
18061890 "" ,
1807- " navigation: 1-6 switch screens tab next screen [ ] filter sub-tabs esc go back" ,
1891+ " navigation: 1-6 switch screens [ ] filter sub-tabs esc go back" ,
18081892 "" ,
1809- " /done <id> mark task done" ,
1810- " /wip <id> mark in progress" ,
1811- " /blocked <id> mark blocked" ,
1812- " /todo <id> reset to todo" ,
1813- " /members show team" ,
1893+ " /done <id> mark task done" ,
1894+ " /wip <id> mark in progress" ,
1895+ " /blocked <id> mark blocked" ,
1896+ " /todo <id> reset to todo" ,
1897+ " /comment <id> <text> add a comment to a task" ,
1898+ " /reply @user <text> reply to last message from a user" ,
1899+ " /react <msg-id> approve|ack|reject react to a result" ,
1900+ " /members show team" ,
18141901 }
18151902 if m .role == "admin" {
18161903 lines = append (lines ,
18171904 ` /assign @user "title" ["desc"] [priority] [due:YYYY-MM-DD] create task` ,
1818- " /delete <id> remove task" ,
1819- " /accept <username> approve a pending join request" ,
1820- " /reject <username> deny a pending join request" ,
1905+ " /delete <id> remove task" ,
1906+ " /accept <username> approve a pending join request" ,
1907+ " /reject <username> deny a pending join request" ,
18211908 )
18221909 }
18231910 lines = append (lines ,
18241911 " /github setup --token <token> --org <org> configure github" ,
1825- " /github refresh reload github data" ,
1826- " /exit quit wert" ,
1912+ " /github refresh reload github data" ,
1913+ " /exit quit wert" ,
18271914 "" ,
18281915 )
18291916 m .prevScreen = m .screen
@@ -1839,6 +1926,81 @@ func (m *Model) handleCommand(text string) tea.Cmd {
18391926 case "/todo" :
18401927 m .updateStatus (fields , protocol .StatusTodo )
18411928
1929+ case "/comment" :
1930+ if len (fields ) < 3 {
1931+ m .statusMsg = "usage: /comment <task-id> <text>"
1932+ return nil
1933+ }
1934+ task := m .findTaskByPrefix (fields [1 ])
1935+ if task == nil {
1936+ m .statusMsg = "task not found: " + fields [1 ]
1937+ return nil
1938+ }
1939+ text := strings .Join (fields [2 :], " " )
1940+ p := protocol.TaskCommentPayload {
1941+ Comment : protocol.TaskComment {
1942+ TaskID : task .ID ,
1943+ Content : text ,
1944+ },
1945+ }
1946+ if data , err := protocol .NewEnvelope (protocol .MsgTaskComment , p ); err == nil {
1947+ m .cl .Send <- data
1948+ }
1949+ m .statusMsg = "comment sent"
1950+
1951+ case "/react" :
1952+ if len (fields ) < 3 {
1953+ m .statusMsg = "usage: /react <msg-id-prefix> approve|ack|reject"
1954+ return nil
1955+ }
1956+ msgID := m .findMessageIDByPrefix (fields [1 ])
1957+ if msgID == "" {
1958+ m .statusMsg = "message not found: " + fields [1 ]
1959+ return nil
1960+ }
1961+ reaction := fields [2 ]
1962+ p := protocol.ResultReactionPayload {
1963+ MessageID : msgID ,
1964+ Reaction : reaction ,
1965+ }
1966+ if data , err := protocol .NewEnvelope (protocol .MsgResultReaction , p ); err == nil {
1967+ m .cl .Send <- data
1968+ }
1969+ m .statusMsg = "reaction sent"
1970+
1971+ case "/reply" :
1972+ // /reply @username <text> — replies to the last message from that user
1973+ if len (fields ) < 3 {
1974+ m .statusMsg = "usage: /reply @username <text>"
1975+ return nil
1976+ }
1977+ target := strings .TrimPrefix (fields [1 ], "@" )
1978+ text := strings .Join (fields [2 :], " " )
1979+ // Find last message from target user.
1980+ var replyToID , replyFrom string
1981+ for i := len (m .messages ) - 1 ; i >= 0 ; i -- {
1982+ if m .messages [i ].From == target {
1983+ replyToID = m .messages [i ].ID
1984+ replyFrom = m .messages [i ].From
1985+ break
1986+ }
1987+ }
1988+ if replyToID == "" {
1989+ m .statusMsg = "no messages found from @" + target
1990+ return nil
1991+ }
1992+ p := protocol.ChatPayload {
1993+ Message : protocol.ChatMessage {
1994+ Content : text ,
1995+ ReplyTo : replyToID ,
1996+ ReplyFrom : replyFrom ,
1997+ },
1998+ }
1999+ if data , err := protocol .NewEnvelope (protocol .MsgChat , p ); err == nil {
2000+ m .cl .Send <- data
2001+ }
2002+ m .screen = scrChat
2003+
18422004 case "/members" :
18432005 lines := []string {"" , " team members:" }
18442006 for _ , mem := range m .members {
@@ -2021,9 +2183,20 @@ func (m Model) applyEnvelope(env protocol.Envelope) Model {
20212183 return m
20222184 }
20232185 cp := p .Task
2024- m .tasks = append (m .tasks , & cp )
2025- if cp .Assignee == m .username {
2026- m .statusMsg = fmt .Sprintf ("* new task: %s" , cp .Title )
2186+ // Upsert: update existing task if found (e.g. after comment/dep changes), otherwise append.
2187+ found := false
2188+ for i , t := range m .tasks {
2189+ if t .ID == cp .ID {
2190+ m .tasks [i ] = & cp
2191+ found = true
2192+ break
2193+ }
2194+ }
2195+ if ! found {
2196+ m .tasks = append (m .tasks , & cp )
2197+ if cp .Assignee == m .username {
2198+ m .statusMsg = fmt .Sprintf ("* new task: %s" , cp .Title )
2199+ }
20272200 }
20282201
20292202 case protocol .MsgTaskUpdate :
@@ -2197,6 +2370,79 @@ func (m Model) applyEnvelope(env protocol.Envelope) Model {
21972370 m .statusMsg = fmt .Sprintf ("* DM from %s" , p .From )
21982371 }
21992372
2373+ case protocol .MsgTaskComment :
2374+ var p protocol.TaskCommentPayload
2375+ if err := json .Unmarshal (env .Payload , & p ); err != nil {
2376+ return m
2377+ }
2378+ // The task is updated via MsgTaskCreate upsert; just show status notification.
2379+ m .statusMsg = fmt .Sprintf ("* comment on task by %s" , p .Comment .Author )
2380+
2381+ case protocol .MsgAgentHandoff :
2382+ var p protocol.AgentHandoffPayload
2383+ if err := json .Unmarshal (env .Payload , & p ); err != nil {
2384+ return m
2385+ }
2386+ m .statusMsg = fmt .Sprintf ("* %s handed off task to %s" , p .From , p .To )
2387+ // Inject as chat message so the handoff is visible in the chat log.
2388+ shortTask := p .TaskID
2389+ if len (shortTask ) > 8 {
2390+ shortTask = shortTask [:8 ]
2391+ }
2392+ content := fmt .Sprintf ("[handoff] task %s → %s" , shortTask , p .To )
2393+ if p .Context != "" {
2394+ content += ": " + p .Context
2395+ }
2396+ msg := & protocol.ChatMessage {
2397+ ID : p .TaskID + "-handoff-" + p .To ,
2398+ From : p .From ,
2399+ Content : content ,
2400+ Timestamp : p .Timestamp ,
2401+ IsAgent : true ,
2402+ Kind : "handoff" ,
2403+ Meta : p .To ,
2404+ }
2405+ m .messages = append (m .messages , msg )
2406+ if m .screen != scrChat {
2407+ m .unreadChat ++
2408+ }
2409+
2410+ case protocol .MsgResultReaction :
2411+ var p protocol.ResultReactionPayload
2412+ if err := json .Unmarshal (env .Payload , & p ); err != nil {
2413+ return m
2414+ }
2415+ // Find the message and update its reactions.
2416+ for _ , msg := range m .messages {
2417+ if msg .ID == p .MessageID {
2418+ updated := false
2419+ for i , r := range msg .Reactions {
2420+ if r .Reactor == p .Reactor {
2421+ msg .Reactions [i ].Reaction = p .Reaction
2422+ msg .Reactions [i ].At = p .At
2423+ updated = true
2424+ break
2425+ }
2426+ }
2427+ if ! updated {
2428+ msg .Reactions = append (msg .Reactions , protocol.ResultReaction {
2429+ Reactor : p .Reactor ,
2430+ Reaction : p .Reaction ,
2431+ At : p .At ,
2432+ })
2433+ }
2434+ break
2435+ }
2436+ }
2437+ m .statusMsg = fmt .Sprintf ("* %s reacted %s" , p .Reactor , p .Reaction )
2438+
2439+ case protocol .MsgPipelineEvent :
2440+ var p protocol.PipelineEventPayload
2441+ if err := json .Unmarshal (env .Payload , & p ); err != nil {
2442+ return m
2443+ }
2444+ m .statusMsg = fmt .Sprintf ("* pipeline %q %s (step %d/%d → %s)" , p .Name , p .Event , p .Step , p .Total , p .Agent )
2445+
22002446 case protocol .MsgAgentOnline :
22012447 var p protocol.AgentOnlinePayload
22022448 if err := json .Unmarshal (env .Payload , & p ); err != nil {
@@ -2260,6 +2506,24 @@ func fetchGitHub(cl *gh.Client) tea.Cmd {
22602506
22612507// ── Join approval helpers ─────────────────────────────────────────────────────
22622508
2509+ func (m * Model ) findTaskByPrefix (prefix string ) * protocol.Task {
2510+ for _ , t := range m .tasks {
2511+ if strings .HasPrefix (t .ID , prefix ) {
2512+ return t
2513+ }
2514+ }
2515+ return nil
2516+ }
2517+
2518+ func (m * Model ) findMessageIDByPrefix (prefix string ) string {
2519+ for i := len (m .messages ) - 1 ; i >= 0 ; i -- {
2520+ if strings .HasPrefix (m .messages [i ].ID , prefix ) {
2521+ return m .messages [i ].ID
2522+ }
2523+ }
2524+ return ""
2525+ }
2526+
22632527func (m * Model ) removePendingJoin (username string ) {
22642528 out := m .pendingJoins [:0 ]
22652529 for _ , u := range m .pendingJoins {
0 commit comments