feat(card): add tagged notes and improve delete mode flow

Add Tag field to CardNote struct and TaggedNote method to CardBuilder for programmatic note handling. Simplify delete mode toggle logic by returning updated cards instead of nil and using native Feishu form checkers. Improve Discord command description truncation using runes.

generated by llmgit

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
chenhg5andClaude committed 2026-03-14 08:46:38 +08:00
1 parent dc5cc3d0df
commit 3f438f0055
6 files changed
+38 -43

No files matched your search

+13 -1
View File
@@ -37,7 +37,12 @@ type CardActions struct {
}
// CardNote renders small footnote text at the bottom.
type CardNote struct{ Text string }
// Tag is an optional machine-readable identifier (not displayed) used by
// platform renderers to recognize and handle specific notes programmatically.
type CardNote struct {
Text string
Tag string
}
// CardListItem renders a row with description text on the left and a button on the right.
// On Feishu this maps to div+extra; on other platforms it degrades to a text line.
@@ -203,6 +208,13 @@ func (b *CardBuilder) Note(text string) *CardBuilder {
return b
}
func (b *CardBuilder) TaggedNote(tag, text string) *CardBuilder {
if text != "" {
b.card.Elements = append(b.card.Elements, CardNote{Text: text, Tag: tag})
}
return b
}
// Build returns the constructed Card.
func (b *CardBuilder) Build() *Card {
c := b.card
+1 -6
View File
@@ -4353,11 +4353,6 @@ func (e *Engine) handleCardNav(action string, sessionKey string) *Card {
case "/switch":
return e.renderListCardSafe(sessionKey, 1)
case "/delete-mode":
if prefix == "act" {
if fields := strings.Fields(args); len(fields) > 0 && fields[0] == "toggle" {
return nil
}
}
if strings.HasPrefix(args, "cancel") {
return e.renderListCardSafe(sessionKey, 1)
}
@@ -4684,7 +4679,7 @@ func (e *Engine) renderDeleteModeSelectCard(sessionKey string, sessions *Session
action,
)
}
cb.Note(e.i18n.Tf(MsgDeleteModeSelectedCount, selectedCount))
cb.TaggedNote("delete-mode-selected-count", e.i18n.Tf(MsgDeleteModeSelectedCount, selectedCount))
if dm.hint != "" {
cb.Note(dm.hint)
}
+6 -3
View File
@@ -1071,7 +1071,7 @@ func TestCmdDelete_NoArgsOnCardPlatformShowsDeleteModeCard(t *testing.T) {
}
}
func TestDeleteMode_ToggleSelectionDoesNotReturnCardButPersistsSelection(t *testing.T) {
func TestDeleteMode_ToggleSelectionReturnsUpdatedCard(t *testing.T) {
p := &stubCardPlatform{stubPlatformEngine: stubPlatformEngine{n: "feishu"}}
agent := &stubDeleteAgent{stubListAgent: stubListAgent{sessions: []AgentSessionInfo{
{ID: "session-1", Summary: "One"},
@@ -1082,8 +1082,11 @@ func TestDeleteMode_ToggleSelectionDoesNotReturnCardButPersistsSelection(t *test
e.cmdDelete(p, msg, nil)
card := e.handleCardNav("act:/delete-mode toggle session-2", msg.SessionKey)
if card != nil {
t.Fatal("expected no card update after toggle")
if card == nil {
t.Fatal("expected card update after toggle")
}
if !strings.Contains(card.RenderText(), "1 selected") {
t.Fatalf("card text = %q, want selected count", card.RenderText())
}
confirmCard := e.handleCardNav("act:/delete-mode confirm", msg.SessionKey)
+3 -3
View File
@@ -79,7 +79,7 @@ func (p *Platform) makeSessionKey(channelID string, userID string) string {
}
}
// RegisterCommands registers bot commands with Telegram for the command menu.
// RegisterCommands registers bot commands with Discord for the slash command menu.
func (p *Platform) RegisterCommands(commands []core.BotCommandInfo) error {
// Wait for Ready event to ensure appID is populated
select {
@@ -95,8 +95,8 @@ func (p *Platform) RegisterCommands(commands []core.BotCommandInfo) error {
continue
}
desc := c.Description
if len(desc) > 100 {
desc = desc[:97] + "..."
if runes := []rune(desc); len(runes) > 100 {
desc = string(runes[:97]) + "..."
}
cmds = append(cmds, &discordgo.ApplicationCommand{
Name: c.Command,
+5 -22
View File
@@ -5,7 +5,6 @@ import (
"encoding/json"
"fmt"
"log/slog"
"regexp"
"strings"
"github.com/chenhg5/cc-connect/core"
@@ -246,7 +245,7 @@ func renderDeleteModeCheckerCard(card *core.Card, base map[string]any) (map[stri
rows := make([]deleteModeCheckerRow, 0)
formRowElements := make([]map[string]any, 0)
notes := make([]string, 0)
notes := make([]core.CardNote, 0)
navRows := make([]core.CardActions, 0)
submitText := ""
cancelText := ""
@@ -282,7 +281,7 @@ func renderDeleteModeCheckerCard(card *core.Card, base map[string]any) (map[stri
},
})
case core.CardNote:
notes = append(notes, e.Text)
notes = append(notes, e)
case core.CardActions:
remaining := make([]core.CardButton, 0, len(e.Buttons))
for _, btn := range e.Buttons {
@@ -309,15 +308,15 @@ func renderDeleteModeCheckerCard(card *core.Card, base map[string]any) (map[stri
elements := make([]map[string]any, 0, len(notes)+1+len(navRows))
for _, n := range notes {
if n == "" {
if n.Text == "" {
continue
}
if isDeleteModeSelectedCountNote(n) {
if n.Tag == "delete-mode-selected-count" {
continue
}
elements = append(elements, map[string]any{
"tag": "note",
"elements": []map[string]any{plainText(n)},
"elements": []map[string]any{plainText(n.Text)},
})
}
formElements := append([]map[string]any{}, formRowElements...)
@@ -429,22 +428,6 @@ func parseDeleteModeListItemAction(action string) (id string, selectable bool, o
}
}
func isDeleteModeSelectedCountNote(text string) bool {
t := strings.TrimSpace(text)
return regexpDeleteModeSelectedCountEn.MatchString(t) ||
regexpDeleteModeSelectedCountZh.MatchString(t) ||
regexpDeleteModeSelectedCountZhTW.MatchString(t) ||
regexpDeleteModeSelectedCountJa.MatchString(t) ||
regexpDeleteModeSelectedCountEs.MatchString(t)
}
var (
regexpDeleteModeSelectedCountEn = regexp.MustCompile(`^\d+\s+selected$`)
regexpDeleteModeSelectedCountZh = regexp.MustCompile(`^已选\s+\d+\s+项$`)
regexpDeleteModeSelectedCountZhTW = regexp.MustCompile(`^已選\s+\d+\s+項$`)
regexpDeleteModeSelectedCountJa = regexp.MustCompile(`^\d+\s+件を選択中$`)
regexpDeleteModeSelectedCountEs = regexp.MustCompile(`^\d+\s+seleccionadas$`)
)
// renderCard converts a core.Card into the Feishu Interactive Card JSON string.
func renderCard(card *core.Card) string {
+10 -8
View File
@@ -238,6 +238,16 @@ func (p *Platform) onCardAction(event *callback.CardActionTriggerEvent) (*callba
// nav: / act: — synchronous card update
if strings.HasPrefix(actionVal, "nav:") || strings.HasPrefix(actionVal, "act:") {
// Feishu uses native form checker for delete-mode toggle,
// so return a toast without calling cardNavHandler to avoid a full card refresh.
if strings.HasPrefix(actionVal, "act:/delete-mode toggle ") {
return &callback.CardActionTriggerResponse{
Toast: &callback.Toast{
Type: "info",
Content: "已记录选择(Selection recorded)",
},
}, nil
}
if p.cardNavHandler != nil {
card := p.cardNavHandler(actionVal, sessionKey)
if card != nil {
@@ -250,14 +260,6 @@ func (p *Platform) onCardAction(event *callback.CardActionTriggerEvent) (*callba
}
}
if strings.HasPrefix(actionVal, "act:") {
if strings.HasPrefix(actionVal, "act:/delete-mode toggle ") {
return &callback.CardActionTriggerResponse{
Toast: &callback.Toast{
Type: "info",
Content: "已记录选择(Selection recorded)",
},
}, nil
}
slog.Debug(p.tag()+": card action produced no card update", "action", actionVal)
return nil, nil
}