Skip to content

Commit d114f1f

Browse files
add thread replies, task comments, deps, reactions, scratchpad, pipelines, handoff, due-date reminders
1 parent ced0da3 commit d114f1f

6 files changed

Lines changed: 1509 additions & 93 deletions

File tree

internal/client/tui/model.go

Lines changed: 281 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
22632527
func (m *Model) removePendingJoin(username string) {
22642528
out := m.pendingJoins[:0]
22652529
for _, u := range m.pendingJoins {

0 commit comments

Comments
 (0)