Refine Telegram thread commands

This commit is contained in:
Codex
2026-06-23 11:19:48 +00:00
parent ac8d5c2803
commit 595e8aee0e
9 changed files with 628 additions and 102 deletions

View File

@@ -33,6 +33,10 @@ const (
telegramDirectiveEnd = " -->"
telegramCaptionLimit = 1024
pictureMediaGroupLimit = 10
threadActionResume = "resume"
threadActionArchive = "archive"
threadActionUnarchive = "unarchive"
threadActionDelete = "delete"
)
type Bot struct {
@@ -50,7 +54,6 @@ type Bot struct {
mu sync.Mutex
outputs map[string]*outputState
diffs map[string]string
}
type assistantMessageSegment struct {
@@ -150,7 +153,6 @@ func NewBot(tg *Client, st *store.Store, codex *codexapp.Client, uploadDir, code
defaultSandbox: defaultSandbox,
pollTimeout: pollTimeout,
outputs: make(map[string]*outputState),
diffs: make(map[string]string),
}
}
@@ -227,19 +229,20 @@ func (b *Bot) clearStaleActiveTurn(ctx context.Context, userID int64, thread sto
func botCommands() []BotCommand {
return []BotCommand{
{Command: "start", Description: "Show help"},
{Command: "new", Description: "Start a new thread"},
{Command: "thread", Description: "List or switch threads"},
{Command: "resume", Description: "List or switch threads"},
{Command: "rename", Description: "Rename a thread"},
{Command: "fork", Description: "Fork the active thread"},
{Command: "archive", Description: "Archive a thread"},
{Command: "unarchive", Description: "Restore an archived thread"},
{Command: "delete", Description: "Delete a thread"},
{Command: "status", Description: "Show active settings"},
{Command: "cancel", Description: "Interrupt the active turn"},
{Command: "workspace", Description: "Select workspace"},
{Command: "model", Description: "Choose model"},
{Command: "sandbox", Description: "Choose sandbox"},
{Command: "pic", Description: "Generate images"},
{Command: "diff", Description: "Show latest diff"},
{Command: "help", Description: "Show help"},
}
}
@@ -297,27 +300,27 @@ func (b *Bot) handleCommand(ctx context.Context, message *Message, session store
chatID := message.Chat.ID
switch command {
case "start", "help":
return true, b.sendHelp(ctx, chatID)
case "start":
return true, b.sendStart(ctx, chatID)
case "new":
_, _, err := b.createNewThread(ctx, userID, chatID, session, true)
return true, err
case "thread":
return true, b.threadCommand(ctx, userID, chatID, args)
case "threads", "resume":
return true, b.legacyThreadCommand(ctx, userID, chatID, args)
case "resume":
return true, b.resumeCommand(ctx, userID, chatID, args)
case "rename":
return true, b.renameThread(ctx, userID, chatID, session, args)
case "fork":
return true, b.forkThread(ctx, userID, chatID, session)
case "archive":
return true, b.archiveThread(ctx, userID, chatID, session, args)
case "unarchive":
return true, b.unarchiveThread(ctx, userID, chatID, session, args)
case "delete":
return true, b.deleteThread(ctx, userID, chatID, session, args)
case "status":
return true, b.sendStatus(ctx, userID, chatID, session)
case "cancel":
return true, b.cancelTurn(ctx, userID, chatID, session)
case "workspaces":
return true, b.sendWorkspaces(ctx, userID, chatID)
case "workspace":
return true, b.handleWorkspaceCommand(ctx, userID, chatID, session, args)
case "model":
@@ -326,44 +329,42 @@ func (b *Bot) handleCommand(ctx context.Context, message *Message, session store
return true, b.handleSandboxCommand(ctx, userID, chatID, session, args)
case "pic":
return true, b.handlePictureCommand(ctx, userID, chatID, session, args)
case "diff":
return true, b.sendDiff(ctx, chatID, session)
default:
_, err := b.tg.SendMessage(ctx, chatID, "Unknown command. Use /help.", SendMessageOptions{})
_, err := b.tg.SendMessage(ctx, chatID, "Unknown command.", SendMessageOptions{})
return true, err
}
}
func (b *Bot) sendHelp(ctx context.Context, chatID int64) error {
func (b *Bot) sendStart(ctx context.Context, chatID int64) error {
text := strings.Join([]string{
"Codex Telegram Bot",
"",
"/new - start a new Codex thread",
"/thread - list recent threads",
"/thread ID - switch to a thread",
"/resume - list recent threads",
"/resume ID - switch to a thread",
"/rename TITLE or /rename ID TITLE - rename a thread",
"/fork - fork the active thread",
"/archive [ID] - archive a thread",
"/archive [ID] - choose or archive a thread",
"/unarchive [ID] - choose or restore an archived thread",
"/delete [ID] - choose or delete a thread",
"/status - show active settings",
"/cancel - interrupt the active turn",
"/workspaces - list workspaces",
"/workspace [ID] - select workspace",
"/model - choose model and reasoning effort",
"/sandbox - choose sandbox",
"/pic PROMPT - generate image(s) from a prompt",
"/diff - show the latest streamed diff",
"",
"Plain text continues the active thread. Images are staged as local Codex image inputs; other files are staged and sent as paths.",
}, "\n")
return b.sendLong(ctx, chatID, text)
}
func (b *Bot) threadCommand(ctx context.Context, userID, chatID int64, args []string) error {
func (b *Bot) resumeCommand(ctx context.Context, userID, chatID int64, args []string) error {
if len(args) == 0 {
return b.sendResumeChoices(ctx, userID, chatID, 0, 0)
}
if len(args) != 1 {
_, err := b.tg.SendMessage(ctx, chatID, "Use /thread to choose a thread, or /thread ID to switch directly.", SendMessageOptions{})
_, err := b.tg.SendMessage(ctx, chatID, "Use /resume to choose a thread, or /resume ID to switch directly.", SendMessageOptions{})
return err
}
id, err := strconv.ParseInt(args[0], 10, 64)
@@ -374,26 +375,29 @@ func (b *Bot) threadCommand(ctx context.Context, userID, chatID int64, args []st
return b.resumeThreadByID(ctx, userID, chatID, id, 0)
}
func (b *Bot) legacyThreadCommand(ctx context.Context, userID, chatID int64, args []string) error {
if len(args) == 0 {
return b.sendResumeChoices(ctx, userID, chatID, 0, 0)
}
return b.threadCommand(ctx, userID, chatID, args)
func (b *Bot) sendResumeChoices(ctx context.Context, userID, chatID int64, page int, messageID int) error {
return b.sendThreadActionChoices(ctx, userID, chatID, threadActionResume, page, messageID)
}
func (b *Bot) sendResumeChoices(ctx context.Context, userID, chatID int64, page int, messageID int) error {
func (b *Bot) sendThreadActionChoices(ctx context.Context, userID, chatID int64, action string, page int, messageID int) error {
if page < 0 {
page = 0
}
threads, err := b.store.ListThreadsPage(ctx, userID, false, resumeThreadPageSize+1, page*resumeThreadPageSize)
var threads []store.Thread
var err error
if action == threadActionUnarchive {
threads, err = b.store.ListArchivedThreadsPage(ctx, userID, resumeThreadPageSize+1, page*resumeThreadPageSize)
} else {
threads, err = b.store.ListThreadsPage(ctx, userID, false, resumeThreadPageSize+1, page*resumeThreadPageSize)
}
if err != nil {
return err
}
if len(threads) == 0 && page > 0 {
return b.sendResumeChoices(ctx, userID, chatID, page-1, messageID)
return b.sendThreadActionChoices(ctx, userID, chatID, action, page-1, messageID)
}
if len(threads) == 0 {
text := "No threads yet. Use /new."
text := noThreadActionChoicesText(action)
if messageID != 0 {
_, err := b.tg.EditMessageText(ctx, chatID, messageID, text, EditMessageTextOptions{})
return err
@@ -406,8 +410,8 @@ func (b *Bot) sendResumeChoices(ctx context.Context, userID, chatID int64, page
if hasNext {
threads = threads[:resumeThreadPageSize]
}
text := resumeThreadListText(threads, page)
markup := resumeThreadMarkup(threads, page, hasNext)
text := threadActionListText(threads, page, action)
markup := threadActionMarkup(threads, page, hasNext, action)
if messageID != 0 {
_, err := b.tg.EditMessageText(ctx, chatID, messageID, EscapeHTML(text), EditMessageTextOptions{ParseMode: "HTML", ReplyMarkup: editReplyMarkup(markup)})
return err
@@ -537,28 +541,149 @@ func (b *Bot) forkThread(ctx context.Context, userID, chatID int64, session stor
}
func (b *Bot) archiveThread(ctx context.Context, userID, chatID int64, session store.Session, args []string) error {
var thread store.Thread
var err error
if len(args) > 0 {
id, parseErr := strconv.ParseInt(args[0], 10, 64)
if parseErr != nil {
_, sendErr := b.tg.SendMessage(ctx, chatID, "Thread ID must be a number.", SendMessageOptions{})
return sendErr
}
thread, err = b.store.GetThreadByID(ctx, userID, id)
} else {
thread, err = b.activeThread(ctx, userID, session)
_ = session
if len(args) == 0 {
return b.sendThreadActionChoices(ctx, userID, chatID, threadActionArchive, 0, 0)
}
id, handled, err := b.threadIDFromArgs(ctx, chatID, args, "Use /archive to choose a thread, or /archive ID.")
if handled {
return err
}
return b.archiveThreadByID(ctx, userID, chatID, id, 0)
}
func (b *Bot) archiveThreadByID(ctx context.Context, userID, chatID int64, id int64, messageID int) error {
thread, err := b.store.GetThreadByID(ctx, userID, id)
if err != nil {
return b.sendNoActiveThread(ctx, chatID, err)
if !errors.Is(err, sql.ErrNoRows) {
return err
}
return b.sendThreadActionNotFound(ctx, chatID, messageID)
}
if err := b.codex.ArchiveThread(ctx, thread.CodexThreadID); err != nil {
return b.sendError(ctx, chatID, "Could not archive Codex thread", err)
if !isMissingCodexThreadError(err) {
return b.sendError(ctx, chatID, "Could not archive Codex thread", err)
}
b.logger.Printf("archive stale local thread #%d codex_thread_id=%s: %v", thread.ID, thread.CodexThreadID, err)
}
if err := b.store.ArchiveThread(ctx, userID, thread.ID); err != nil {
return err
}
_, err = b.tg.SendMessage(ctx, chatID, fmt.Sprintf("Archived thread #%d.", thread.ID), SendMessageOptions{})
text := fmt.Sprintf("Archived thread #%d.", thread.ID)
if messageID != 0 {
_, err = b.tg.EditMessageText(ctx, chatID, messageID, text, EditMessageTextOptions{ReplyMarkup: clearInlineKeyboardMarkup()})
return err
}
_, err = b.tg.SendMessage(ctx, chatID, text, SendMessageOptions{})
return err
}
func (b *Bot) unarchiveThread(ctx context.Context, userID, chatID int64, session store.Session, args []string) error {
_ = session
if len(args) == 0 {
return b.sendThreadActionChoices(ctx, userID, chatID, threadActionUnarchive, 0, 0)
}
id, handled, err := b.threadIDFromArgs(ctx, chatID, args, "Use /unarchive to choose a thread, or /unarchive ID.")
if handled {
return err
}
return b.unarchiveThreadByID(ctx, userID, chatID, id, 0)
}
func (b *Bot) unarchiveThreadByID(ctx context.Context, userID, chatID int64, id int64, messageID int) error {
thread, err := b.store.GetThreadByID(ctx, userID, id)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
return err
}
return b.sendThreadActionNotFound(ctx, chatID, messageID)
}
if err := b.codex.UnarchiveThread(ctx, thread.CodexThreadID); err != nil {
return b.sendError(ctx, chatID, "Could not unarchive Codex thread", err)
}
if err := b.store.UnarchiveThread(ctx, userID, thread.ID); err != nil {
return err
}
text := fmt.Sprintf("Restored thread #%d.", thread.ID)
if messageID != 0 {
_, err = b.tg.EditMessageText(ctx, chatID, messageID, text, EditMessageTextOptions{ReplyMarkup: clearInlineKeyboardMarkup()})
return err
}
_, err = b.tg.SendMessage(ctx, chatID, text, SendMessageOptions{})
return err
}
func (b *Bot) deleteThread(ctx context.Context, userID, chatID int64, session store.Session, args []string) error {
_ = session
if len(args) == 0 {
return b.sendThreadActionChoices(ctx, userID, chatID, threadActionDelete, 0, 0)
}
id, handled, err := b.threadIDFromArgs(ctx, chatID, args, "Use /delete to choose a thread, or /delete ID.")
if handled {
return err
}
return b.deleteThreadByID(ctx, userID, chatID, id, 0)
}
func (b *Bot) deleteThreadByID(ctx context.Context, userID, chatID int64, id int64, messageID int) error {
thread, err := b.store.GetThreadByID(ctx, userID, id)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
return err
}
return b.sendThreadActionNotFound(ctx, chatID, messageID)
}
if err := b.codex.DeleteThread(ctx, thread.CodexThreadID); err != nil {
if !isMissingCodexThreadError(err) {
return b.sendError(ctx, chatID, "Could not delete Codex thread", err)
}
b.logger.Printf("delete stale local thread #%d codex_thread_id=%s: %v", thread.ID, thread.CodexThreadID, err)
}
b.clearOutput(thread.CodexThreadID)
if err := b.store.DeleteThread(ctx, userID, thread.ID); err != nil {
return err
}
text := fmt.Sprintf("Deleted thread #%d.", thread.ID)
if messageID != 0 {
_, err = b.tg.EditMessageText(ctx, chatID, messageID, text, EditMessageTextOptions{ReplyMarkup: clearInlineKeyboardMarkup()})
return err
}
_, err = b.tg.SendMessage(ctx, chatID, text, SendMessageOptions{})
return err
}
func isMissingCodexThreadError(err error) bool {
var rpcErr codexapp.RPCError
if !errors.As(err, &rpcErr) {
return false
}
if rpcErr.Code != -32600 {
return false
}
message := strings.ToLower(rpcErr.Message)
return strings.Contains(message, "no rollout found") || strings.Contains(message, "thread not loaded")
}
func (b *Bot) threadIDFromArgs(ctx context.Context, chatID int64, args []string, usage string) (int64, bool, error) {
if len(args) != 1 {
_, err := b.tg.SendMessage(ctx, chatID, usage, SendMessageOptions{})
return 0, true, err
}
id, err := strconv.ParseInt(args[0], 10, 64)
if err != nil || id <= 0 {
_, sendErr := b.tg.SendMessage(ctx, chatID, "Thread ID must be a number.", SendMessageOptions{})
return 0, true, sendErr
}
return id, false, nil
}
func (b *Bot) sendThreadActionNotFound(ctx context.Context, chatID int64, messageID int) error {
text := "Thread not found."
if messageID != 0 {
_, err := b.tg.EditMessageText(ctx, chatID, messageID, text, EditMessageTextOptions{ReplyMarkup: clearInlineKeyboardMarkup()})
return err
}
_, err := b.tg.SendMessage(ctx, chatID, text, SendMessageOptions{})
return err
}
@@ -842,26 +967,6 @@ func isPicturePath(path string) bool {
}
}
func (b *Bot) sendDiff(ctx context.Context, chatID int64, session store.Session) error {
if session.ActiveThreadID == 0 {
_, err := b.tg.SendMessage(ctx, chatID, "No active thread.", SendMessageOptions{})
return err
}
thread, err := b.store.GetThreadByID(ctx, chatID, session.ActiveThreadID)
if err != nil {
_, sendErr := b.tg.SendMessage(ctx, chatID, "No active thread.", SendMessageOptions{})
return sendErr
}
b.mu.Lock()
diff := b.diffs[thread.CodexThreadID]
b.mu.Unlock()
if diff == "" {
_, err := b.tg.SendMessage(ctx, chatID, "No diff has been streamed for this thread.", SendMessageOptions{})
return err
}
return b.sendLong(ctx, chatID, diff)
}
func (b *Bot) continueThread(ctx context.Context, message *Message, session store.Session) error {
userID := message.From.ID
chatID := message.Chat.ID
@@ -1221,6 +1326,15 @@ func (b *Bot) handleCallback(ctx context.Context, callback *CallbackQuery) error
}
return b.sendResumeChoices(ctx, callback.From.ID, callback.Message.Chat.ID, resumePage, callback.Message.MessageID)
}
if action, threadID, ok := ParseThreadActionCallbackData(callback.Data); ok {
return b.handleThreadActionCallback(ctx, callback, action, threadID)
}
if action, page, ok := ParseThreadActionPageCallbackData(callback.Data); ok {
if err := b.tg.AnswerCallbackQuery(ctx, callback.ID, ""); err != nil {
return err
}
return b.sendThreadActionChoices(ctx, callback.From.ID, callback.Message.Chat.ID, action, page, callback.Message.MessageID)
}
if modelID, ok := ParseModelCallbackData(callback.Data); ok {
return b.handleModelCallback(ctx, callback, modelID)
}
@@ -1236,6 +1350,33 @@ func (b *Bot) handleCallback(ctx context.Context, callback *CallbackQuery) error
return b.tg.AnswerCallbackQuery(ctx, callback.ID, "Unknown action.")
}
func (b *Bot) handleThreadActionCallback(ctx context.Context, callback *CallbackQuery, action string, threadID int64) error {
switch action {
case threadActionResume:
if err := b.tg.AnswerCallbackQuery(ctx, callback.ID, "Thread selected."); err != nil {
return err
}
return b.resumeThreadByID(ctx, callback.From.ID, callback.Message.Chat.ID, threadID, callback.Message.MessageID)
case threadActionArchive:
if err := b.tg.AnswerCallbackQuery(ctx, callback.ID, "Archiving thread."); err != nil {
return err
}
return b.archiveThreadByID(ctx, callback.From.ID, callback.Message.Chat.ID, threadID, callback.Message.MessageID)
case threadActionUnarchive:
if err := b.tg.AnswerCallbackQuery(ctx, callback.ID, "Restoring thread."); err != nil {
return err
}
return b.unarchiveThreadByID(ctx, callback.From.ID, callback.Message.Chat.ID, threadID, callback.Message.MessageID)
case threadActionDelete:
if err := b.tg.AnswerCallbackQuery(ctx, callback.ID, "Deleting thread."); err != nil {
return err
}
return b.deleteThreadByID(ctx, callback.From.ID, callback.Message.Chat.ID, threadID, callback.Message.MessageID)
default:
return b.tg.AnswerCallbackQuery(ctx, callback.ID, "Unknown thread action.")
}
}
func (b *Bot) handleApprovalCallback(ctx context.Context, callback *CallbackQuery, approvalID int64, decision string) error {
approval, err := b.store.GetPendingApproval(ctx, callback.From.ID, approvalID)
if err != nil {
@@ -1550,6 +1691,8 @@ func argumentLabel(key string) string {
return "CWD"
case "cmd":
return "cmd"
case "environmentid":
return "Environment ID"
}
label := strings.ReplaceAll(key, "_", " ")
return strings.ToUpper(label[:1]) + label[1:]
@@ -1804,20 +1947,6 @@ func (b *Bot) handleCodexNotification(ctx context.Context, event codexapp.Event)
if params.ThreadID != "" && b.hasOutputThread(params.ThreadID) {
return b.sendOutputBlock(ctx, params.ThreadID, "Codex warning: "+params.Message)
}
case "turn/diff/updated":
var params struct {
ThreadID string `json:"threadId"`
TurnID string `json:"turnId"`
Diff string `json:"diff"`
}
if err := json.Unmarshal(event.Params, &params); err != nil {
return err
}
if params.ThreadID != "" && b.shouldHandleOutputEvent(params.ThreadID, params.TurnID) {
b.mu.Lock()
b.diffs[params.ThreadID] = params.Diff
b.mu.Unlock()
}
case "turn/completed":
var params struct {
ThreadID string `json:"threadId"`
@@ -1854,6 +1983,17 @@ func (b *Bot) handleCodexNotification(ctx context.Context, event codexapp.Event)
}
return b.store.SyncThreadTitleByCodexID(ctx, params.ThreadID, title)
}
case "thread/deleted":
var params struct {
ThreadID string `json:"threadId"`
}
if err := json.Unmarshal(event.Params, &params); err != nil {
return err
}
if params.ThreadID != "" {
b.clearOutput(params.ThreadID)
return b.store.DeleteThreadByCodexID(ctx, params.ThreadID)
}
case "thread/settings/updated":
var params struct {
ThreadID string `json:"threadId"`
@@ -3090,20 +3230,28 @@ func parseCommand(text string) (string, []string, bool) {
}
func resumeThreadListText(threads []store.Thread, page int) string {
lines := []string{fmt.Sprintf("Threads (page %d):", page+1), ""}
return threadActionListText(threads, page, threadActionResume)
}
func threadActionListText(threads []store.Thread, page int, action string) string {
lines := []string{fmt.Sprintf("%s (page %d):", threadActionListTitle(action), page+1), ""}
for _, thread := range threads {
lines = append(lines, fmt.Sprintf("Thread ID %d: %s", thread.ID, threadDisplayTitle(thread)))
}
lines = append(lines, "", "Choose a button below, or use /thread THREAD_ID directly.")
lines = append(lines, "", threadActionListFooter(action))
return strings.Join(lines, "\n")
}
func resumeThreadMarkup(threads []store.Thread, page int, hasNext bool) *InlineKeyboardMarkup {
return threadActionMarkup(threads, page, hasNext, threadActionResume)
}
func threadActionMarkup(threads []store.Thread, page int, hasNext bool, action string) *InlineKeyboardMarkup {
keyboard := make([][]InlineKeyboardButton, 0, 4)
for _, thread := range threads {
button := InlineKeyboardButton{
Text: fmt.Sprintf("ID %d", thread.ID),
CallbackData: ResumeThreadCallbackData(thread.ID),
Text: threadActionButtonLabel(action, thread.ID),
CallbackData: threadActionButtonCallback(action, thread.ID),
}
if len(keyboard) == 0 || len(keyboard[len(keyboard)-1]) >= 4 {
keyboard = append(keyboard, []InlineKeyboardButton{button})
@@ -3113,10 +3261,10 @@ func resumeThreadMarkup(threads []store.Thread, page int, hasNext bool) *InlineK
}
var nav []InlineKeyboardButton
if page > 0 {
nav = append(nav, InlineKeyboardButton{Text: "Prev", CallbackData: ResumePageCallbackData(page - 1)})
nav = append(nav, InlineKeyboardButton{Text: "Prev", CallbackData: threadActionPageCallback(action, page-1)})
}
if hasNext {
nav = append(nav, InlineKeyboardButton{Text: "Next", CallbackData: ResumePageCallbackData(page + 1)})
nav = append(nav, InlineKeyboardButton{Text: "Next", CallbackData: threadActionPageCallback(action, page+1)})
}
if len(nav) > 0 {
keyboard = append(keyboard, nav)
@@ -3124,6 +3272,72 @@ func resumeThreadMarkup(threads []store.Thread, page int, hasNext bool) *InlineK
return &InlineKeyboardMarkup{InlineKeyboard: keyboard}
}
func noThreadActionChoicesText(action string) string {
switch action {
case threadActionArchive:
return "No threads to archive."
case threadActionUnarchive:
return "No archived threads to restore."
case threadActionDelete:
return "No threads to delete."
default:
return "No threads yet. Use /new."
}
}
func threadActionListTitle(action string) string {
switch action {
case threadActionArchive:
return "Choose a thread to archive"
case threadActionUnarchive:
return "Choose an archived thread to restore"
case threadActionDelete:
return "Choose a thread to delete"
default:
return "Threads"
}
}
func threadActionListFooter(action string) string {
switch action {
case threadActionArchive:
return "Choose a button below, or use /archive THREAD_ID directly."
case threadActionUnarchive:
return "Choose a button below, or use /unarchive THREAD_ID directly."
case threadActionDelete:
return "Choose a button below, or use /delete THREAD_ID directly."
default:
return "Choose a button below, or use /resume THREAD_ID directly."
}
}
func threadActionButtonLabel(action string, id int64) string {
switch action {
case threadActionArchive:
return fmt.Sprintf("Archive %d", id)
case threadActionUnarchive:
return fmt.Sprintf("Restore %d", id)
case threadActionDelete:
return fmt.Sprintf("Delete %d", id)
default:
return fmt.Sprintf("ID %d", id)
}
}
func threadActionButtonCallback(action string, id int64) string {
if action == threadActionResume {
return ResumeThreadCallbackData(id)
}
return ThreadActionCallbackData(action, id)
}
func threadActionPageCallback(action string, page int) string {
if action == threadActionResume {
return ResumePageCallbackData(page)
}
return ThreadActionPageCallbackData(action, page)
}
func normalizeThreadTitle(title string) string {
title = strings.Join(strings.Fields(title), " ")
runes := []rune(title)
@@ -3610,6 +3824,7 @@ func renderApprovalPayloadDetailsHTML(raw json.RawMessage, params map[string]any
}
appendPart(renderApprovalFieldHTML("cwd", params["cwd"]))
appendPart(renderApprovalFieldHTML("environmentId", params["environmentId"]))
appendPart(renderApprovalFieldHTML("command", params["command"]))
appendPart(renderApprovalFieldHTML("parsedCmd", params["parsedCmd"]))
appendPart(renderApprovalFieldHTML("additionalPermissions", params["additionalPermissions"]))