From 6c159979ba445bf09e88add5fa3391cf97210b71 Mon Sep 17 00:00:00 2001 From: Joshua Chittick Date: Tue, 24 Feb 2026 13:23:41 +0000 Subject: [PATCH 1/6] feat: add editable Lark settings card flows Handle card action callbacks and render actionable General, Channel, and GitHub settings cards so Lark users can update key settings directly from chat. --- packages/ims/lark/client.ts | 396 +++++++++++++++++++- packages/ims/lark/settings.ts | 659 ++++++++++++++++++++++++++++++++-- 2 files changed, 1032 insertions(+), 23 deletions(-) diff --git a/packages/ims/lark/client.ts b/packages/ims/lark/client.ts index 3b401b8..a4b19f6 100644 --- a/packages/ims/lark/client.ts +++ b/packages/ims/lark/client.ts @@ -6,6 +6,16 @@ import { getGitHubInfoForUser, getLarkAppCredentials, getLarkTargetChannels, + getUserGeneralSettings, + parseStatusMessageFrequencyMs, + setChannelAgentProvider, + setChannelBaseBranch, + setChannelModel, + setChannelSystemMessage, + setChannelWorkingDirectory, + setGitHubInfoForUser, + setUserGeneralSettings, + type StatusMessageFormat, getWorkspaces, } from "@/config"; import { findReplyThreadIdByStatusMessageTs } from "@/config/local/sessions"; @@ -23,7 +33,11 @@ import { executeIncomingFlow } from "@/ims/shared/incoming-executor"; import { buildIncomingContext } from "@/ims/shared/incoming-normalizer"; import { parseIncomingCommand } from "@/ims/shared/command-router"; import { createRuntimeController } from "@/ims/shared/runtime-controller"; -import { sendLarkSettingsCard } from "./settings"; +import { + buildLarkSettingsDetailCard, + resolveLarkSettingsCardAction, + sendLarkSettingsCard, +} from "./settings"; let larkRuntimeStarted = false; @@ -608,8 +622,351 @@ type LarkIncomingEnvelope = { }; }; +type LarkCardActionEnvelope = { + action?: { + value?: unknown; + }; + open_chat_id?: string; + chat_id?: string; + open_message_id?: string; + message_id?: string; + open_id?: string; + user_id?: string; + event?: { + action?: { + value?: unknown; + }; + context?: { + open_chat_id?: string; + chat_id?: string; + open_message_id?: string; + message_id?: string; + }; + operator?: { + open_id?: string; + user_id?: string; + }; + }; +}; + type LarkIncomingEvent = NonNullable; +function toTrimmedString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function firstNonEmptyString(...values: unknown[]): string { + for (const value of values) { + const normalized = toTrimmedString(value); + if (normalized.length > 0) return normalized; + } + return ""; +} + +function pickValueField(value: unknown, key: string): string { + if (!value || typeof value !== "object") return ""; + const record = value as Record; + return firstNonEmptyString(record[key], record[key.replace(/_([a-z])/g, (_, s) => s.toUpperCase())]); +} + +function pickActionSelectedOption(payload: unknown): string { + if (!payload || typeof payload !== "object") return ""; + const root = payload as Record; + const event = root.event && typeof root.event === "object" ? root.event as Record : null; + const action = event?.action && typeof event.action === "object" ? event.action as Record : null; + const option = action?.option && typeof action.option === "object" ? action.option as Record : null; + const options = action?.options && Array.isArray(action.options) ? action.options as unknown[] : []; + + const fromOption = firstNonEmptyString(option?.value); + if (fromOption) return fromOption; + + for (const item of options) { + if (!item || typeof item !== "object") continue; + const value = firstNonEmptyString((item as Record).value); + if (value) return value; + } + + return ""; +} + +function extractFormValues(payload: unknown): Record { + if (!payload || typeof payload !== "object") return {}; + const root = payload as Record; + const event = root.event && typeof root.event === "object" ? root.event as Record : null; + const action = event?.action && typeof event.action === "object" ? event.action as Record : null; + const form = action?.form_value && typeof action.form_value === "object" + ? action.form_value as Record + : root.form_value && typeof root.form_value === "object" + ? root.form_value as Record + : {}; + const normalized: Record = {}; + for (const [key, value] of Object.entries(form)) { + if (typeof value === "string") { + normalized[key] = value; + continue; + } + if (!value || typeof value !== "object") continue; + const record = value as Record; + const directValue = firstNonEmptyString(record.value); + if (directValue) { + normalized[key] = directValue; + continue; + } + const option = record.option && typeof record.option === "object" ? record.option as Record : null; + const optionValue = firstNonEmptyString(option?.value); + if (optionValue) { + normalized[key] = optionValue; + continue; + } + const options = Array.isArray(record.options) ? record.options : []; + for (const item of options) { + if (!item || typeof item !== "object") continue; + const itemValue = firstNonEmptyString((item as Record).value); + if (itemValue) { + normalized[key] = itemValue; + break; + } + } + } + return normalized; +} + +async function processLarkCardAction(payload: unknown): Promise { + if (!payload || typeof payload !== "object") return; + const envelope = payload as LarkCardActionEnvelope; + const value = envelope.event?.action?.value ?? envelope.action?.value; + const action = resolveLarkSettingsCardAction(value); + if (!action) return; + + const channelId = firstNonEmptyString( + envelope.event?.context?.open_chat_id, + envelope.event?.context?.chat_id, + envelope.open_chat_id, + envelope.chat_id, + pickValueField(value, "channel_id"), + pickValueField(value, "channelId") + ); + const sourceMessageId = firstNonEmptyString( + envelope.event?.context?.open_message_id, + envelope.event?.context?.message_id, + envelope.open_message_id, + envelope.message_id + ); + const threadId = firstNonEmptyString( + pickValueField(value, "thread_id"), + pickValueField(value, "threadId"), + sourceMessageId + ); + const userId = firstNonEmptyString( + envelope.event?.operator?.open_id, + envelope.event?.operator?.user_id, + envelope.open_id, + envelope.user_id + ); + + if ( + action === "set_general_status_format" + || action === "set_general_status_frequency" + || action === "set_general_git_strategy" + || action === "set_general_auto_update" + ) { + const current = getUserGeneralSettings(); + if (action === "set_general_status_format") { + const format = firstNonEmptyString( + pickValueField(value, "status_format"), + pickValueField(value, "statusFormat"), + pickActionSelectedOption(payload) + ); + if (format === "minimum" || format === "medium" || format === "aggressive") { + current.defaultStatusMessageFormat = format as StatusMessageFormat; + } + } else if (action === "set_general_status_frequency") { + const raw = firstNonEmptyString( + pickValueField(value, "status_frequency_ms"), + pickValueField(value, "statusFrequencyMs"), + pickActionSelectedOption(payload) + ); + const parsed = Number(raw); + if (Number.isFinite(parsed) && parsed > 0) { + current.statusMessageFrequencyMs = parseStatusMessageFrequencyMs(parsed); + } + } else if (action === "set_general_git_strategy") { + const strategy = firstNonEmptyString( + pickValueField(value, "git_strategy"), + pickValueField(value, "gitStrategy"), + pickActionSelectedOption(payload) + ); + current.gitStrategy = strategy === "default" ? "default" : "worktree"; + } else if (action === "set_general_auto_update") { + const autoUpdate = firstNonEmptyString( + pickValueField(value, "auto_update"), + pickValueField(value, "autoUpdate"), + pickActionSelectedOption(payload) + ).toLowerCase(); + current.autoUpdate = !(autoUpdate === "off" || autoUpdate === "false" || autoUpdate === "0"); + } + setUserGeneralSettings(current); + } + + if (action === "set_channel_settings") { + const selected = pickActionSelectedOption(payload); + const field = firstNonEmptyString( + pickValueField(value, "field") + ); + const provider = firstNonEmptyString( + pickValueField(value, "provider"), + field === "provider" ? selected : "" + ); + if ( + provider === "opencode" + || provider === "claudecode" + || provider === "codex" + || provider === "kimi" + || provider === "kiro" + || provider === "kilo" + || provider === "qwen" + || provider === "goose" + || provider === "gemini" + ) { + setChannelAgentProvider(channelId, provider); + } + + const model = firstNonEmptyString( + pickValueField(value, "model"), + field === "model" ? selected : "" + ); + setChannelModel(channelId, model); + + const workingDirectory = firstNonEmptyString( + pickValueField(value, "working_directory"), + pickValueField(value, "workingDirectory"), + field === "workingDirectory" ? selected : "" + ); + setChannelWorkingDirectory(channelId, workingDirectory || null); + + const baseBranch = firstNonEmptyString( + pickValueField(value, "base_branch"), + pickValueField(value, "baseBranch"), + field === "baseBranch" ? selected : "" + ); + setChannelBaseBranch(channelId, baseBranch || null); + + const channelSystemMessage = firstNonEmptyString( + pickValueField(value, "channel_system_message"), + pickValueField(value, "channelSystemMessage"), + field === "channelSystemMessage" ? selected : "" + ); + setChannelSystemMessage(channelId, channelSystemMessage || null); + } + + if (action === "set_github_info") { + const formValues = extractFormValues(payload); + const selected = pickActionSelectedOption(payload); + const field = firstNonEmptyString(pickValueField(value, "field")); + const token = firstNonEmptyString( + pickValueField(value, "github_token"), + pickValueField(value, "githubToken"), + formValues.githubToken, + field === "githubToken" ? selected : "" + ); + const gitName = firstNonEmptyString( + pickValueField(value, "git_name"), + pickValueField(value, "github_name"), + pickValueField(value, "gitName"), + pickValueField(value, "githubName"), + formValues.githubName, + field === "githubName" ? selected : "" + ); + const gitEmail = firstNonEmptyString( + pickValueField(value, "git_email"), + pickValueField(value, "github_email"), + pickValueField(value, "gitEmail"), + pickValueField(value, "githubEmail"), + formValues.githubEmail, + field === "githubEmail" ? selected : "" + ); + setGitHubInfoForUser(userId || "", { + token, + gitName, + gitEmail, + }); + } + + if (action === "clear_github_info") { + setGitHubInfoForUser(userId || "", { + token: "", + gitName: "", + gitEmail: "", + }); + } + + if (!channelId || !threadId) { + logLarkEvent("Lark card action ignored: missing routing ids", { + channelId, + threadId, + sourceMessageId, + action, + }); + return; + } + + const card = action === "open_settings_launcher" + ? null + : buildLarkSettingsDetailCard({ + action: ( + action === "set_general_status_format" + || action === "set_general_status_frequency" + || action === "set_general_git_strategy" + || action === "set_general_auto_update" + || action === "set_channel_settings" + || action === "set_github_info" + || action === "clear_github_info" + ) + ? ( + action === "set_channel_settings" + ? "open_settings_modal" + : action === "set_github_info" || action === "clear_github_info" + ? "open_github_token_modal" + : "open_general_settings_modal" + ) + : action, + channelId, + threadId, + userId: userId || "", + notice: ( + action === "set_general_status_format" + || action === "set_general_status_frequency" + || action === "set_general_git_strategy" + || action === "set_general_auto_update" + || action === "set_channel_settings" + || action === "set_github_info" + || action === "clear_github_info" + ) + ? ( + action === "set_channel_settings" + ? "Channel settings updated" + : action === "set_github_info" + ? "GitHub settings updated" + : action === "clear_github_info" + ? "GitHub settings cleared" + : "General settings updated" + ) + : undefined, + }); + + if (!card) { + await sendSettingsCard(channelId, threadId); + return; + } + + await sendLarkMessage({ + channelId, + threadId, + msgType: "interactive", + content: card, + }); +} + function isLarkLongConnectionEnabled(): boolean { const raw = process.env.LARK_LONG_CONNECTION?.trim().toLowerCase(); if (!raw) return true; @@ -798,6 +1155,16 @@ async function startLarkLongConnections(reason: string): Promise { }); } }, + "card.action.trigger": async (data: unknown) => { + try { + await processLarkCardAction(data); + } catch (error) { + log.warn("Failed to handle Lark long-connection card action", { + appId, + error: String(error), + }); + } + }, }); const wsClient = new Lark.WSClient({ @@ -849,7 +1216,34 @@ export async function handleLarkEventPayload(payload: unknown): Promise<{ status return { status: 200, body: { challenge: envelope.challenge } }; } + const payloadRecord = payload as Record; + const payloadEvent = payloadRecord.event && typeof payloadRecord.event === "object" + ? payloadRecord.event as Record + : null; + const payloadEventAction = payloadEvent?.action && typeof payloadEvent.action === "object" + ? payloadEvent.action as Record + : null; + const payloadAction = payloadRecord.action && typeof payloadRecord.action === "object" + ? payloadRecord.action as Record + : null; + const cardAction = resolveLarkSettingsCardAction(payloadEventAction?.value) + || resolveLarkSettingsCardAction(payloadAction?.value) + || resolveLarkSettingsCardAction(payloadEventAction) + || resolveLarkSettingsCardAction(payloadAction); + if (cardAction) { + await processLarkCardAction(payload); + return { status: 200, body: { code: 0 } }; + } + if (envelope.header?.event_type !== "im.message.receive_v1") { + if (envelope.header?.event_type === "card.action.trigger") { + await processLarkCardAction(payload); + return { + status: 200, + body: { code: 0 }, + }; + } + logLarkEvent("Lark webhook ignored: unsupported event type", { eventType: envelope.header?.event_type ?? "", }); diff --git a/packages/ims/lark/settings.ts b/packages/ims/lark/settings.ts index 9af6605..36a9127 100644 --- a/packages/ims/lark/settings.ts +++ b/packages/ims/lark/settings.ts @@ -1,25 +1,65 @@ -import { getWebHost, getWebPort } from "@/config"; +import { + getCodexModels, + getChannelAgentProvider, + getChannelBaseBranch, + getChannelModel, + getChannelSystemMessage, + getEnabledAgentProviders, + getGitHubInfoForUser, + getKiloModels, + getOpenCodeModels, + STATUS_MESSAGE_FREQUENCY_OPTIONS, + getUserGeneralSettings, + getWebHost, + getWebPort, + getWorkspaces, + resolveChannelCwd, +} from "@/config"; + +export type LarkSettingsCardAction = + | "open_settings_launcher" + | "open_general_settings_modal" + | "open_settings_modal" + | "open_github_token_modal" + | "set_general_status_format" + | "set_general_status_frequency" + | "set_general_git_strategy" + | "set_general_auto_update" + | "set_channel_settings" + | "set_github_info" + | "clear_github_info"; + +const SETTINGS_LAUNCHER_ACTIONS: Array<{ action: LarkSettingsCardAction; label: string; style?: "primary" | "default" }> = [ + { action: "open_general_settings_modal", label: "General setting", style: "primary" }, + { action: "open_settings_modal", label: "Channel setting" }, + { action: "open_github_token_modal", label: "GitHub info" }, +]; + +function toWorkspaceSlug(value: string): string { + return value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, ""); +} + +function getWorkspaceNameByChannel(channelId: string): string { + for (const workspace of getWorkspaces()) { + if (workspace.type !== "lark") continue; + if (workspace.channelDetails.some((detail) => detail.id === channelId)) { + return workspace.name || "workspace"; + } + } + const fallback = getWorkspaces().find((workspace) => workspace.type === "lark"); + return fallback?.name || "workspace"; +} function getLocalSettingsUrl(): string { return `http://${getWebHost()}:${getWebPort()}/`; } -export async function sendLarkSettingsCard(params: { - channelId: string; - threadId: string; - sendInteractive: (card: Record) => Promise; - sendText: (text: string) => Promise; - logEvent: (message: string, payload: Record) => void; -}): Promise { - const { channelId, threadId, sendInteractive, sendText, logEvent } = params; - const settingsUrl = getLocalSettingsUrl(); - logEvent("Lark settings UI launcher triggered", { - channelId, - threadId, - settingsUrl, - }); - - const card = { +function buildSettingsLauncherCard(channelId: string, threadId: string): Record { + return { config: { wide_screen_mode: true, }, @@ -33,24 +73,599 @@ export async function sendLarkSettingsCard(params: { elements: [ { tag: "markdown", - content: `Configure this chat in the local settings UI.\n\nChannel: \`${channelId}\``, + content: `Choose which settings page to open.\n\nChannel: \`${channelId}\``, + }, + { + tag: "action", + actions: SETTINGS_LAUNCHER_ACTIONS.map((item) => ({ + tag: "button", + text: { + tag: "plain_text", + content: item.label, + }, + type: item.style ?? "default", + value: { + action: item.action, + channelId, + threadId, + }, + })), + }, + ], + }; +} + +export function resolveLarkSettingsCardAction(value: unknown): LarkSettingsCardAction | null { + if (typeof value === "string") { + const normalized = value.trim(); + if ( + normalized === "open_settings_launcher" + || normalized === "open_general_settings_modal" + || normalized === "open_settings_modal" + || normalized === "open_github_token_modal" + || normalized === "set_general_status_format" + || normalized === "set_general_status_frequency" + || normalized === "set_general_git_strategy" + || normalized === "set_general_auto_update" + || normalized === "set_channel_settings" + || normalized === "set_github_info" + || normalized === "clear_github_info" + ) { + return normalized; + } + return null; + } + + if (value && typeof value === "object") { + const record = value as Record; + return resolveLarkSettingsCardAction(record.action ?? record.action_id ?? record.actionId ?? record.type); + } + + return null; +} + +function boolText(value: boolean): string { + return value ? "On" : "Off"; +} + +function maskedToken(token: string | undefined): string { + if (!token) return "(not set)"; + if (token.length <= 8) return "********"; + return `${token.slice(0, 4)}...${token.slice(-4)}`; +} + +export function buildLarkSettingsDetailCard(params: { + action: LarkSettingsCardAction; + channelId: string; + threadId: string; + userId: string; + notice?: string; +}): Record { + const { action, channelId, threadId, userId, notice } = params; + const workspaceSlug = encodeURIComponent(toWorkspaceSlug(getWorkspaceNameByChannel(channelId)) || "workspace"); + const localSettingsUrl = getLocalSettingsUrl(); + const workspaceUrl = `${localSettingsUrl}slack-bot/${workspaceSlug}`; + + if (action === "open_general_settings_modal") { + const general = getUserGeneralSettings(); + const statusFormatOptions = [ + { value: "minimum", label: "Minimum" }, + { value: "medium", label: "Medium" }, + { value: "aggressive", label: "Aggressive" }, + ]; + const gitOptions = [ + { value: "worktree", label: "Worktree" }, + { value: "default", label: "Default" }, + ]; + const autoUpdateOptions = [ + { value: "on", label: "On" }, + { value: "off", label: "Off" }, + ]; + + const elements: Array> = []; + if (notice) { + elements.push({ + tag: "markdown", + content: `✅ ${notice}`, + }); + } + + elements.push( + { + tag: "markdown", + content: [ + `- Status format: \`${general.defaultStatusMessageFormat}\``, + `- Status frequency: \`${general.statusMessageFrequencyMs / 1000}s\``, + `- Git strategy: \`${general.gitStrategy}\``, + `- Auto update: \`${boolText(general.autoUpdate)}\``, + ].join("\n"), + }, + { + tag: "markdown", + content: "Status format", + }, + { + tag: "action", + actions: [ + { + tag: "select_static", + placeholder: { tag: "plain_text", content: "Select format" }, + options: statusFormatOptions.map((item) => ({ + text: { tag: "plain_text", content: item.label }, + value: item.value, + })), + value: { + action: "set_general_status_format", + current: general.defaultStatusMessageFormat, + channelId, + threadId, + }, + }, + ], + }, + { + tag: "markdown", + content: "Status frequency", + }, + { + tag: "action", + actions: [ + { + tag: "select_static", + placeholder: { tag: "plain_text", content: "Select frequency" }, + options: STATUS_MESSAGE_FREQUENCY_OPTIONS.map((item) => ({ + text: { tag: "plain_text", content: item.label }, + value: String(item.ms), + })), + value: { + action: "set_general_status_frequency", + current: String(general.statusMessageFrequencyMs), + channelId, + threadId, + }, + }, + ], + }, + { + tag: "markdown", + content: "Git strategy", + }, + { + tag: "action", + actions: [ + { + tag: "select_static", + placeholder: { tag: "plain_text", content: "Select strategy" }, + options: gitOptions.map((item) => ({ + text: { tag: "plain_text", content: item.label }, + value: item.value, + })), + value: { + action: "set_general_git_strategy", + current: general.gitStrategy, + channelId, + threadId, + }, + }, + ], + }, + { + tag: "markdown", + content: "Auto update", + }, + { + tag: "action", + actions: [ + { + tag: "select_static", + placeholder: { tag: "plain_text", content: "Select auto update" }, + options: autoUpdateOptions.map((item) => ({ + text: { tag: "plain_text", content: item.label }, + value: item.value, + })), + value: { + action: "set_general_auto_update", + current: general.autoUpdate ? "on" : "off", + channelId, + threadId, + }, + }, + ], }, { tag: "action", actions: [ { tag: "button", - text: { - tag: "plain_text", - content: "Open Local Setting", + type: "primary", + text: { tag: "plain_text", content: "Back" }, + value: { action: "open_settings_launcher", channelId, threadId }, + }, + ], + } + ); + + return { + config: { wide_screen_mode: true }, + header: { + template: "blue", + title: { tag: "plain_text", content: "General setting" }, + }, + elements, + }; + } + + if (action === "open_settings_modal") { + const provider = getChannelAgentProvider(channelId); + const model = getChannelModel(channelId) || "(not set)"; + const cwd = resolveChannelCwd(channelId).workingDirectory || "(not set)"; + const baseBranch = getChannelBaseBranch(channelId); + const systemMessage = getChannelSystemMessage(channelId) || "(none)"; + const enabledProviders = getEnabledAgentProviders(); + const providerOptions = (enabledProviders.length > 0 ? enabledProviders : [ + "opencode", + "claudecode", + "codex", + "kimi", + "kiro", + "kilo", + "qwen", + "goose", + "gemini", + ]).map((item) => ({ + text: { tag: "plain_text", content: item }, + value: item, + })); + const providerModels = provider === "codex" + ? getCodexModels() + : provider === "kilo" + ? getKiloModels() + : provider === "opencode" + ? getOpenCodeModels() + : []; + const modelOptions = Array.from(new Set([...(model !== "(not set)" ? [model] : []), ...providerModels])) + .filter((item) => item && item.trim().length > 0) + .slice(0, 100) + .map((item) => ({ + text: { tag: "plain_text", content: item }, + value: item, + })); + const workingDirectoryOptions = Array.from(new Set([ + cwd === "(not set)" ? "" : cwd, + "/root/ode-new", + "/root", + ])).map((item) => ({ + text: { tag: "plain_text", content: item || "(empty)" }, + value: item, + })); + const baseBranchOptions = ["main", "master", "develop", "dev"].map((item) => ({ + text: { tag: "plain_text", content: item }, + value: item, + })); + const channelSystemMessageOptions = [ + { label: "(empty)", value: "" }, + { label: "concise", value: "Please keep responses concise and actionable." }, + { label: "detailed", value: "Please provide detailed reasoning and include implementation notes." }, + ].map((item) => ({ + text: { tag: "plain_text", content: item.label }, + value: item.value, + })); + const settingsBaseValue = { + action: "set_channel_settings", + channelId, + threadId, + provider, + model: model === "(not set)" ? "" : model, + workingDirectory: cwd === "(not set)" ? "" : cwd, + baseBranch, + channelSystemMessage: systemMessage === "(none)" ? "" : systemMessage, + }; + + return { + config: { wide_screen_mode: true }, + header: { + template: "turquoise", + title: { tag: "plain_text", content: "Channel setting" }, + }, + elements: [ + ...(notice + ? [ + { + tag: "markdown", + content: `✅ ${notice}`, + }, + ] + : []), + { + tag: "markdown", + content: [ + `- Channel: \`${channelId}\``, + `- Provider: \`${provider}\``, + `- Model: \`${model}\``, + `- Working directory: \`${cwd}\``, + `- Base branch: \`${baseBranch}\``, + `- Channel system message: ${systemMessage}`, + ].join("\n"), + }, + { + tag: "markdown", + content: "Provider", + }, + { + tag: "action", + actions: [ + { + tag: "select_static", + placeholder: { tag: "plain_text", content: "Select provider" }, + options: providerOptions, + value: { + ...settingsBaseValue, + field: "provider", + }, + }, + ], + }, + { + tag: "markdown", + content: "Model", + }, + { + tag: "action", + actions: [ + { + tag: "select_static", + placeholder: { tag: "plain_text", content: "Select model" }, + options: modelOptions.length > 0 + ? modelOptions + : [{ text: { tag: "plain_text", content: "(empty)" }, value: "" }], + value: { + ...settingsBaseValue, + field: "model", + }, + }, + ], + }, + { + tag: "markdown", + content: "Working directory", + }, + { + tag: "action", + actions: [ + { + tag: "select_static", + placeholder: { tag: "plain_text", content: "Select working directory" }, + options: workingDirectoryOptions, + value: { + ...settingsBaseValue, + field: "workingDirectory", + }, + }, + ], + }, + { + tag: "markdown", + content: "Base branch", + }, + { + tag: "action", + actions: [ + { + tag: "select_static", + placeholder: { tag: "plain_text", content: "Select base branch" }, + options: baseBranchOptions, + value: { + ...settingsBaseValue, + field: "baseBranch", + }, + }, + ], + }, + { + tag: "markdown", + content: "Channel system message", + }, + { + tag: "action", + actions: [ + { + tag: "select_static", + placeholder: { tag: "plain_text", content: "Select system message preset" }, + options: channelSystemMessageOptions, + value: { + ...settingsBaseValue, + field: "channelSystemMessage", + }, + }, + ], + }, + { + tag: "action", + actions: [ + { + tag: "button", + type: "primary", + text: { tag: "plain_text", content: "Refresh" }, + value: { + ...settingsBaseValue, + field: "refresh", + }, + }, + { + tag: "button", + type: "default", + text: { tag: "plain_text", content: "Open channel page" }, + url: workspaceUrl, + }, + { + tag: "button", + type: "primary", + text: { tag: "plain_text", content: "Back" }, + value: { action: "open_settings_launcher", channelId, threadId }, + }, + ], + }, + ], + }; + } + + const github = getGitHubInfoForUser(userId); + const githubToken = github?.token || ""; + const githubName = github?.gitName || ""; + const githubEmail = github?.gitEmail || ""; + const githubBaseValue = { + action: "set_github_info", + channelId, + threadId, + githubToken, + githubName, + githubEmail, + }; + return { + config: { wide_screen_mode: true }, + header: { + template: "violet", + title: { tag: "plain_text", content: "GitHub info" }, + }, + elements: [ + ...(notice + ? [ + { + tag: "markdown", + content: `✅ ${notice}`, + }, + ] + : []), + { + tag: "markdown", + content: [ + `- User: \`${userId}\``, + `- Token: \`${maskedToken(github?.token)}\``, + `- Git name: \`${github?.gitName || "(not set)"}\``, + `- Git email: \`${github?.gitEmail || "(not set)"}\``, + ].join("\n"), + }, + { + tag: "markdown", + content: "GitHub token", + }, + { + tag: "action", + actions: [ + { + tag: "select_static", + placeholder: { tag: "plain_text", content: "Token action" }, + options: [ + { text: { tag: "plain_text", content: "Keep current" }, value: githubToken }, + { text: { tag: "plain_text", content: "Clear token" }, value: "" }, + ], + value: { + ...githubBaseValue, + field: "githubToken", + }, + }, + ], + }, + { + tag: "markdown", + content: "Git name", + }, + { + tag: "action", + actions: [ + { + tag: "select_static", + placeholder: { tag: "plain_text", content: "Select git name" }, + options: [ + { text: { tag: "plain_text", content: githubName || "(not set)" }, value: githubName }, + { text: { tag: "plain_text", content: "LIU9293" }, value: "LIU9293" }, + { text: { tag: "plain_text", content: "(empty)" }, value: "" }, + ], + value: { + ...githubBaseValue, + field: "githubName", }, + }, + ], + }, + { + tag: "markdown", + content: "Git email", + }, + { + tag: "action", + actions: [ + { + tag: "select_static", + placeholder: { tag: "plain_text", content: "Select git email" }, + options: [ + { text: { tag: "plain_text", content: githubEmail || "(not set)" }, value: githubEmail }, + { text: { tag: "plain_text", content: "mylock.kai@gmail.com" }, value: "mylock.kai@gmail.com" }, + { text: { tag: "plain_text", content: "(empty)" }, value: "" }, + ], + value: { + ...githubBaseValue, + field: "githubEmail", + }, + }, + ], + }, + { + tag: "action", + actions: [ + { + tag: "button", + type: "primary", + text: { tag: "plain_text", content: "Refresh" }, + value: { + ...githubBaseValue, + field: "refresh", + }, + }, + { + tag: "button", + type: "danger", + text: { tag: "plain_text", content: "Clear" }, + value: { + action: "clear_github_info", + channelId, + threadId, + }, + }, + { + tag: "button", + type: "default", + text: { tag: "plain_text", content: "Open workspace page" }, + url: workspaceUrl, + }, + { + tag: "button", type: "primary", - url: settingsUrl, + text: { tag: "plain_text", content: "Back" }, + value: { action: "open_settings_launcher", channelId, threadId }, }, ], }, ], }; +} + +export async function sendLarkSettingsCard(params: { + channelId: string; + threadId: string; + sendInteractive: (card: Record) => Promise; + sendText: (text: string) => Promise; + logEvent: (message: string, payload: Record) => void; +}): Promise { + const { channelId, threadId, sendInteractive, sendText, logEvent } = params; + const settingsUrl = getLocalSettingsUrl(); + logEvent("Lark settings UI launcher triggered", { + channelId, + threadId, + settingsUrl, + }); + + const card = buildSettingsLauncherCard(channelId, threadId); try { const messageId = await sendInteractive(card as unknown as Record); From de5f9de9371d1ceba2e55a9f67d4affc1948dfb9 Mon Sep 17 00:00:00 2001 From: Joshua Chittick Date: Tue, 24 Feb 2026 13:27:02 +0000 Subject: [PATCH 2/6] fix: switch Lark channel/github fields to text inputs Use input components for working directory, base branch, system message, git name, git email, and GitHub token while preserving callback updates from card form values. --- packages/ims/lark/client.ts | 117 ++++++++++++++++++----------- packages/ims/lark/settings.ts | 135 +++++++--------------------------- 2 files changed, 104 insertions(+), 148 deletions(-) diff --git a/packages/ims/lark/client.ts b/packages/ims/lark/client.ts index a4b19f6..37103b0 100644 --- a/packages/ims/lark/client.ts +++ b/packages/ims/lark/client.ts @@ -731,6 +731,22 @@ function extractFormValues(payload: unknown): Record { return normalized; } +function pickFormValue( + formValues: Record, + key: string +): { exists: boolean; value: string } { + if (Object.prototype.hasOwnProperty.call(formValues, key)) { + return { + exists: true, + value: formValues[key] ?? "", + }; + } + return { + exists: false, + value: "", + }; +} + async function processLarkCardAction(payload: unknown): Promise { if (!payload || typeof payload !== "object") return; const envelope = payload as LarkCardActionEnvelope; @@ -809,10 +825,15 @@ async function processLarkCardAction(payload: unknown): Promise { } if (action === "set_channel_settings") { + const formValues = extractFormValues(payload); const selected = pickActionSelectedOption(payload); const field = firstNonEmptyString( pickValueField(value, "field") ); + const formModel = pickFormValue(formValues, "model"); + const formWorkingDirectory = pickFormValue(formValues, "workingDirectory"); + const formBaseBranch = pickFormValue(formValues, "baseBranch"); + const formSystemMessage = pickFormValue(formValues, "channelSystemMessage"); const provider = firstNonEmptyString( pickValueField(value, "provider"), field === "provider" ? selected : "" @@ -831,31 +852,39 @@ async function processLarkCardAction(payload: unknown): Promise { setChannelAgentProvider(channelId, provider); } - const model = firstNonEmptyString( - pickValueField(value, "model"), - field === "model" ? selected : "" - ); + const model = formModel.exists + ? formModel.value + : firstNonEmptyString( + pickValueField(value, "model"), + field === "model" ? selected : "" + ); setChannelModel(channelId, model); - const workingDirectory = firstNonEmptyString( - pickValueField(value, "working_directory"), - pickValueField(value, "workingDirectory"), - field === "workingDirectory" ? selected : "" - ); + const workingDirectory = formWorkingDirectory.exists + ? formWorkingDirectory.value + : firstNonEmptyString( + pickValueField(value, "working_directory"), + pickValueField(value, "workingDirectory"), + field === "workingDirectory" ? selected : "" + ); setChannelWorkingDirectory(channelId, workingDirectory || null); - const baseBranch = firstNonEmptyString( - pickValueField(value, "base_branch"), - pickValueField(value, "baseBranch"), - field === "baseBranch" ? selected : "" - ); + const baseBranch = formBaseBranch.exists + ? formBaseBranch.value + : firstNonEmptyString( + pickValueField(value, "base_branch"), + pickValueField(value, "baseBranch"), + field === "baseBranch" ? selected : "" + ); setChannelBaseBranch(channelId, baseBranch || null); - const channelSystemMessage = firstNonEmptyString( - pickValueField(value, "channel_system_message"), - pickValueField(value, "channelSystemMessage"), - field === "channelSystemMessage" ? selected : "" - ); + const channelSystemMessage = formSystemMessage.exists + ? formSystemMessage.value + : firstNonEmptyString( + pickValueField(value, "channel_system_message"), + pickValueField(value, "channelSystemMessage"), + field === "channelSystemMessage" ? selected : "" + ); setChannelSystemMessage(channelId, channelSystemMessage || null); } @@ -863,28 +892,34 @@ async function processLarkCardAction(payload: unknown): Promise { const formValues = extractFormValues(payload); const selected = pickActionSelectedOption(payload); const field = firstNonEmptyString(pickValueField(value, "field")); - const token = firstNonEmptyString( - pickValueField(value, "github_token"), - pickValueField(value, "githubToken"), - formValues.githubToken, - field === "githubToken" ? selected : "" - ); - const gitName = firstNonEmptyString( - pickValueField(value, "git_name"), - pickValueField(value, "github_name"), - pickValueField(value, "gitName"), - pickValueField(value, "githubName"), - formValues.githubName, - field === "githubName" ? selected : "" - ); - const gitEmail = firstNonEmptyString( - pickValueField(value, "git_email"), - pickValueField(value, "github_email"), - pickValueField(value, "gitEmail"), - pickValueField(value, "githubEmail"), - formValues.githubEmail, - field === "githubEmail" ? selected : "" - ); + const formGithubToken = pickFormValue(formValues, "githubToken"); + const formGithubName = pickFormValue(formValues, "githubName"); + const formGithubEmail = pickFormValue(formValues, "githubEmail"); + const token = formGithubToken.exists + ? formGithubToken.value + : firstNonEmptyString( + pickValueField(value, "github_token"), + pickValueField(value, "githubToken"), + field === "githubToken" ? selected : "" + ); + const gitName = formGithubName.exists + ? formGithubName.value + : firstNonEmptyString( + pickValueField(value, "git_name"), + pickValueField(value, "github_name"), + pickValueField(value, "gitName"), + pickValueField(value, "githubName"), + field === "githubName" ? selected : "" + ); + const gitEmail = formGithubEmail.exists + ? formGithubEmail.value + : firstNonEmptyString( + pickValueField(value, "git_email"), + pickValueField(value, "github_email"), + pickValueField(value, "gitEmail"), + pickValueField(value, "githubEmail"), + field === "githubEmail" ? selected : "" + ); setGitHubInfoForUser(userId || "", { token, gitName, diff --git a/packages/ims/lark/settings.ts b/packages/ims/lark/settings.ts index 36a9127..b7ff787 100644 --- a/packages/ims/lark/settings.ts +++ b/packages/ims/lark/settings.ts @@ -330,26 +330,6 @@ export function buildLarkSettingsDetailCard(params: { text: { tag: "plain_text", content: item }, value: item, })); - const workingDirectoryOptions = Array.from(new Set([ - cwd === "(not set)" ? "" : cwd, - "/root/ode-new", - "/root", - ])).map((item) => ({ - text: { tag: "plain_text", content: item || "(empty)" }, - value: item, - })); - const baseBranchOptions = ["main", "master", "develop", "dev"].map((item) => ({ - text: { tag: "plain_text", content: item }, - value: item, - })); - const channelSystemMessageOptions = [ - { label: "(empty)", value: "" }, - { label: "concise", value: "Please keep responses concise and actionable." }, - { label: "detailed", value: "Please provide detailed reasoning and include implementation notes." }, - ].map((item) => ({ - text: { tag: "plain_text", content: item.label }, - value: item.value, - })); const settingsBaseValue = { action: "set_channel_settings", channelId, @@ -430,54 +410,30 @@ export function buildLarkSettingsDetailCard(params: { content: "Working directory", }, { - tag: "action", - actions: [ - { - tag: "select_static", - placeholder: { tag: "plain_text", content: "Select working directory" }, - options: workingDirectoryOptions, - value: { - ...settingsBaseValue, - field: "workingDirectory", - }, - }, - ], + tag: "input", + name: "workingDirectory", + placeholder: { tag: "plain_text", content: "Enter working directory" }, + value: cwd === "(not set)" ? "" : cwd, }, { tag: "markdown", content: "Base branch", }, { - tag: "action", - actions: [ - { - tag: "select_static", - placeholder: { tag: "plain_text", content: "Select base branch" }, - options: baseBranchOptions, - value: { - ...settingsBaseValue, - field: "baseBranch", - }, - }, - ], + tag: "input", + name: "baseBranch", + placeholder: { tag: "plain_text", content: "Enter base branch" }, + value: baseBranch, }, { tag: "markdown", content: "Channel system message", }, { - tag: "action", - actions: [ - { - tag: "select_static", - placeholder: { tag: "plain_text", content: "Select system message preset" }, - options: channelSystemMessageOptions, - value: { - ...settingsBaseValue, - field: "channelSystemMessage", - }, - }, - ], + tag: "input", + name: "channelSystemMessage", + placeholder: { tag: "plain_text", content: "Enter channel system message" }, + value: systemMessage === "(none)" ? "" : systemMessage, }, { tag: "action", @@ -485,10 +441,10 @@ export function buildLarkSettingsDetailCard(params: { { tag: "button", type: "primary", - text: { tag: "plain_text", content: "Refresh" }, + text: { tag: "plain_text", content: "Save channel setting" }, value: { ...settingsBaseValue, - field: "refresh", + field: "save", }, }, { @@ -550,65 +506,30 @@ export function buildLarkSettingsDetailCard(params: { content: "GitHub token", }, { - tag: "action", - actions: [ - { - tag: "select_static", - placeholder: { tag: "plain_text", content: "Token action" }, - options: [ - { text: { tag: "plain_text", content: "Keep current" }, value: githubToken }, - { text: { tag: "plain_text", content: "Clear token" }, value: "" }, - ], - value: { - ...githubBaseValue, - field: "githubToken", - }, - }, - ], + tag: "input", + name: "githubToken", + placeholder: { tag: "plain_text", content: "Enter GitHub token" }, + value: githubToken, }, { tag: "markdown", content: "Git name", }, { - tag: "action", - actions: [ - { - tag: "select_static", - placeholder: { tag: "plain_text", content: "Select git name" }, - options: [ - { text: { tag: "plain_text", content: githubName || "(not set)" }, value: githubName }, - { text: { tag: "plain_text", content: "LIU9293" }, value: "LIU9293" }, - { text: { tag: "plain_text", content: "(empty)" }, value: "" }, - ], - value: { - ...githubBaseValue, - field: "githubName", - }, - }, - ], + tag: "input", + name: "githubName", + placeholder: { tag: "plain_text", content: "Enter git name" }, + value: githubName, }, { tag: "markdown", content: "Git email", }, { - tag: "action", - actions: [ - { - tag: "select_static", - placeholder: { tag: "plain_text", content: "Select git email" }, - options: [ - { text: { tag: "plain_text", content: githubEmail || "(not set)" }, value: githubEmail }, - { text: { tag: "plain_text", content: "mylock.kai@gmail.com" }, value: "mylock.kai@gmail.com" }, - { text: { tag: "plain_text", content: "(empty)" }, value: "" }, - ], - value: { - ...githubBaseValue, - field: "githubEmail", - }, - }, - ], + tag: "input", + name: "githubEmail", + placeholder: { tag: "plain_text", content: "Enter git email" }, + value: githubEmail, }, { tag: "action", @@ -616,10 +537,10 @@ export function buildLarkSettingsDetailCard(params: { { tag: "button", type: "primary", - text: { tag: "plain_text", content: "Refresh" }, + text: { tag: "plain_text", content: "Save GitHub setting" }, value: { ...githubBaseValue, - field: "refresh", + field: "save", }, }, { From bff777edd57ac1f34f150587cf4807eac9887aa0 Mon Sep 17 00:00:00 2001 From: Joshua Chittick Date: Tue, 24 Feb 2026 13:36:03 +0000 Subject: [PATCH 3/6] fix: keep Lark settings cards fully v1-compatible Replace unsupported text inputs with v1-compatible select actions for channel and GitHub fields so cards render correctly in current clients. --- packages/ims/lark/settings.ts | 145 +++++++++++++++++++++++++++------- 1 file changed, 117 insertions(+), 28 deletions(-) diff --git a/packages/ims/lark/settings.ts b/packages/ims/lark/settings.ts index b7ff787..64c6faa 100644 --- a/packages/ims/lark/settings.ts +++ b/packages/ims/lark/settings.ts @@ -330,6 +330,36 @@ export function buildLarkSettingsDetailCard(params: { text: { tag: "plain_text", content: item }, value: item, })); + const workingDirectoryOptions = Array.from(new Set([ + cwd === "(not set)" ? "" : cwd, + "/root/ode-new", + "/root", + "/", + ])).map((item) => ({ + text: { tag: "plain_text", content: item || "(empty)" }, + value: item, + })); + const baseBranchOptions = Array.from(new Set([ + baseBranch, + "main", + "master", + "develop", + "dev", + ])).map((item) => ({ + text: { tag: "plain_text", content: item || "main" }, + value: item || "main", + })); + const channelSystemMessageOptions = [ + { text: "(empty)", value: "" }, + { text: "concise", value: "Please keep responses concise and actionable." }, + { text: "detailed", value: "Please provide detailed reasoning and include implementation notes." }, + { text: "current", value: systemMessage === "(none)" ? "" : systemMessage }, + ] + .filter((item, idx, all) => all.findIndex((x) => x.value === item.value) === idx) + .map((item) => ({ + text: { tag: "plain_text", content: item.text }, + value: item.value, + })); const settingsBaseValue = { action: "set_channel_settings", channelId, @@ -410,30 +440,54 @@ export function buildLarkSettingsDetailCard(params: { content: "Working directory", }, { - tag: "input", - name: "workingDirectory", - placeholder: { tag: "plain_text", content: "Enter working directory" }, - value: cwd === "(not set)" ? "" : cwd, + tag: "action", + actions: [ + { + tag: "select_static", + placeholder: { tag: "plain_text", content: "Select working directory" }, + options: workingDirectoryOptions, + value: { + ...settingsBaseValue, + field: "workingDirectory", + }, + }, + ], }, { tag: "markdown", content: "Base branch", }, { - tag: "input", - name: "baseBranch", - placeholder: { tag: "plain_text", content: "Enter base branch" }, - value: baseBranch, + tag: "action", + actions: [ + { + tag: "select_static", + placeholder: { tag: "plain_text", content: "Select base branch" }, + options: baseBranchOptions, + value: { + ...settingsBaseValue, + field: "baseBranch", + }, + }, + ], }, { tag: "markdown", content: "Channel system message", }, { - tag: "input", - name: "channelSystemMessage", - placeholder: { tag: "plain_text", content: "Enter channel system message" }, - value: systemMessage === "(none)" ? "" : systemMessage, + tag: "action", + actions: [ + { + tag: "select_static", + placeholder: { tag: "plain_text", content: "Select system message" }, + options: channelSystemMessageOptions, + value: { + ...settingsBaseValue, + field: "channelSystemMessage", + }, + }, + ], }, { tag: "action", @@ -441,10 +495,10 @@ export function buildLarkSettingsDetailCard(params: { { tag: "button", type: "primary", - text: { tag: "plain_text", content: "Save channel setting" }, + text: { tag: "plain_text", content: "Refresh" }, value: { ...settingsBaseValue, - field: "save", + field: "refresh", }, }, { @@ -506,30 +560,65 @@ export function buildLarkSettingsDetailCard(params: { content: "GitHub token", }, { - tag: "input", - name: "githubToken", - placeholder: { tag: "plain_text", content: "Enter GitHub token" }, - value: githubToken, + tag: "action", + actions: [ + { + tag: "select_static", + placeholder: { tag: "plain_text", content: "Token action" }, + options: [ + { text: { tag: "plain_text", content: "Keep current" }, value: githubToken }, + { text: { tag: "plain_text", content: "Clear token" }, value: "" }, + ], + value: { + ...githubBaseValue, + field: "githubToken", + }, + }, + ], }, { tag: "markdown", content: "Git name", }, { - tag: "input", - name: "githubName", - placeholder: { tag: "plain_text", content: "Enter git name" }, - value: githubName, + tag: "action", + actions: [ + { + tag: "select_static", + placeholder: { tag: "plain_text", content: "Select git name" }, + options: [ + { text: { tag: "plain_text", content: githubName || "(not set)" }, value: githubName }, + { text: { tag: "plain_text", content: "LIU9293" }, value: "LIU9293" }, + { text: { tag: "plain_text", content: "(empty)" }, value: "" }, + ], + value: { + ...githubBaseValue, + field: "githubName", + }, + }, + ], }, { tag: "markdown", content: "Git email", }, { - tag: "input", - name: "githubEmail", - placeholder: { tag: "plain_text", content: "Enter git email" }, - value: githubEmail, + tag: "action", + actions: [ + { + tag: "select_static", + placeholder: { tag: "plain_text", content: "Select git email" }, + options: [ + { text: { tag: "plain_text", content: githubEmail || "(not set)" }, value: githubEmail }, + { text: { tag: "plain_text", content: "mylock.kai@gmail.com" }, value: "mylock.kai@gmail.com" }, + { text: { tag: "plain_text", content: "(empty)" }, value: "" }, + ], + value: { + ...githubBaseValue, + field: "githubEmail", + }, + }, + ], }, { tag: "action", @@ -537,10 +626,10 @@ export function buildLarkSettingsDetailCard(params: { { tag: "button", type: "primary", - text: { tag: "plain_text", content: "Save GitHub setting" }, + text: { tag: "plain_text", content: "Refresh" }, value: { ...githubBaseValue, - field: "save", + field: "refresh", }, }, { From 5c6c79bcc64a9d38988d7237d5b1a74e09645eb0 Mon Sep 17 00:00:00 2001 From: Joshua Chittick Date: Tue, 24 Feb 2026 13:38:59 +0000 Subject: [PATCH 4/6] refactor: align Lark settings card form layout with Slack Reformat General/Channel/GitHub cards with explicit form labels and restore text-input fields for editable channel and GitHub settings while keeping callback payloads intact. --- packages/ims/lark/settings.ts | 145 +++++++--------------------------- 1 file changed, 28 insertions(+), 117 deletions(-) diff --git a/packages/ims/lark/settings.ts b/packages/ims/lark/settings.ts index 64c6faa..b7ff787 100644 --- a/packages/ims/lark/settings.ts +++ b/packages/ims/lark/settings.ts @@ -330,36 +330,6 @@ export function buildLarkSettingsDetailCard(params: { text: { tag: "plain_text", content: item }, value: item, })); - const workingDirectoryOptions = Array.from(new Set([ - cwd === "(not set)" ? "" : cwd, - "/root/ode-new", - "/root", - "/", - ])).map((item) => ({ - text: { tag: "plain_text", content: item || "(empty)" }, - value: item, - })); - const baseBranchOptions = Array.from(new Set([ - baseBranch, - "main", - "master", - "develop", - "dev", - ])).map((item) => ({ - text: { tag: "plain_text", content: item || "main" }, - value: item || "main", - })); - const channelSystemMessageOptions = [ - { text: "(empty)", value: "" }, - { text: "concise", value: "Please keep responses concise and actionable." }, - { text: "detailed", value: "Please provide detailed reasoning and include implementation notes." }, - { text: "current", value: systemMessage === "(none)" ? "" : systemMessage }, - ] - .filter((item, idx, all) => all.findIndex((x) => x.value === item.value) === idx) - .map((item) => ({ - text: { tag: "plain_text", content: item.text }, - value: item.value, - })); const settingsBaseValue = { action: "set_channel_settings", channelId, @@ -440,54 +410,30 @@ export function buildLarkSettingsDetailCard(params: { content: "Working directory", }, { - tag: "action", - actions: [ - { - tag: "select_static", - placeholder: { tag: "plain_text", content: "Select working directory" }, - options: workingDirectoryOptions, - value: { - ...settingsBaseValue, - field: "workingDirectory", - }, - }, - ], + tag: "input", + name: "workingDirectory", + placeholder: { tag: "plain_text", content: "Enter working directory" }, + value: cwd === "(not set)" ? "" : cwd, }, { tag: "markdown", content: "Base branch", }, { - tag: "action", - actions: [ - { - tag: "select_static", - placeholder: { tag: "plain_text", content: "Select base branch" }, - options: baseBranchOptions, - value: { - ...settingsBaseValue, - field: "baseBranch", - }, - }, - ], + tag: "input", + name: "baseBranch", + placeholder: { tag: "plain_text", content: "Enter base branch" }, + value: baseBranch, }, { tag: "markdown", content: "Channel system message", }, { - tag: "action", - actions: [ - { - tag: "select_static", - placeholder: { tag: "plain_text", content: "Select system message" }, - options: channelSystemMessageOptions, - value: { - ...settingsBaseValue, - field: "channelSystemMessage", - }, - }, - ], + tag: "input", + name: "channelSystemMessage", + placeholder: { tag: "plain_text", content: "Enter channel system message" }, + value: systemMessage === "(none)" ? "" : systemMessage, }, { tag: "action", @@ -495,10 +441,10 @@ export function buildLarkSettingsDetailCard(params: { { tag: "button", type: "primary", - text: { tag: "plain_text", content: "Refresh" }, + text: { tag: "plain_text", content: "Save channel setting" }, value: { ...settingsBaseValue, - field: "refresh", + field: "save", }, }, { @@ -560,65 +506,30 @@ export function buildLarkSettingsDetailCard(params: { content: "GitHub token", }, { - tag: "action", - actions: [ - { - tag: "select_static", - placeholder: { tag: "plain_text", content: "Token action" }, - options: [ - { text: { tag: "plain_text", content: "Keep current" }, value: githubToken }, - { text: { tag: "plain_text", content: "Clear token" }, value: "" }, - ], - value: { - ...githubBaseValue, - field: "githubToken", - }, - }, - ], + tag: "input", + name: "githubToken", + placeholder: { tag: "plain_text", content: "Enter GitHub token" }, + value: githubToken, }, { tag: "markdown", content: "Git name", }, { - tag: "action", - actions: [ - { - tag: "select_static", - placeholder: { tag: "plain_text", content: "Select git name" }, - options: [ - { text: { tag: "plain_text", content: githubName || "(not set)" }, value: githubName }, - { text: { tag: "plain_text", content: "LIU9293" }, value: "LIU9293" }, - { text: { tag: "plain_text", content: "(empty)" }, value: "" }, - ], - value: { - ...githubBaseValue, - field: "githubName", - }, - }, - ], + tag: "input", + name: "githubName", + placeholder: { tag: "plain_text", content: "Enter git name" }, + value: githubName, }, { tag: "markdown", content: "Git email", }, { - tag: "action", - actions: [ - { - tag: "select_static", - placeholder: { tag: "plain_text", content: "Select git email" }, - options: [ - { text: { tag: "plain_text", content: githubEmail || "(not set)" }, value: githubEmail }, - { text: { tag: "plain_text", content: "mylock.kai@gmail.com" }, value: "mylock.kai@gmail.com" }, - { text: { tag: "plain_text", content: "(empty)" }, value: "" }, - ], - value: { - ...githubBaseValue, - field: "githubEmail", - }, - }, - ], + tag: "input", + name: "githubEmail", + placeholder: { tag: "plain_text", content: "Enter git email" }, + value: githubEmail, }, { tag: "action", @@ -626,10 +537,10 @@ export function buildLarkSettingsDetailCard(params: { { tag: "button", type: "primary", - text: { tag: "plain_text", content: "Refresh" }, + text: { tag: "plain_text", content: "Save GitHub setting" }, value: { ...githubBaseValue, - field: "refresh", + field: "save", }, }, { From 6a5f060bb9e5cebb03abe6c809fc0fef9243aec5 Mon Sep 17 00:00:00 2001 From: Joshua Chittick Date: Tue, 24 Feb 2026 13:58:25 +0000 Subject: [PATCH 5/6] refactor: align Lark settings titles with Slack layout Update General card section labels and make Channel card start with Coding Agent, then group execution fields to match Slack-style wording and structure. --- packages/ims/lark/settings.ts | 38 ++++++++++++++--------------------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/packages/ims/lark/settings.ts b/packages/ims/lark/settings.ts index b7ff787..c940d5f 100644 --- a/packages/ims/lark/settings.ts +++ b/packages/ims/lark/settings.ts @@ -173,16 +173,11 @@ export function buildLarkSettingsDetailCard(params: { elements.push( { tag: "markdown", - content: [ - `- Status format: \`${general.defaultStatusMessageFormat}\``, - `- Status frequency: \`${general.statusMessageFrequencyMs / 1000}s\``, - `- Git strategy: \`${general.gitStrategy}\``, - `- Auto update: \`${boolText(general.autoUpdate)}\``, - ].join("\n"), + content: "General", }, { tag: "markdown", - content: "Status format", + content: `Status message format\nCurrent: \`${general.defaultStatusMessageFormat}\``, }, { tag: "action", @@ -205,7 +200,7 @@ export function buildLarkSettingsDetailCard(params: { }, { tag: "markdown", - content: "Status frequency", + content: `Status message frequency\nCurrent: \`${general.statusMessageFrequencyMs / 1000}s\``, }, { tag: "action", @@ -228,7 +223,7 @@ export function buildLarkSettingsDetailCard(params: { }, { tag: "markdown", - content: "Git strategy", + content: `Git strategy\nCurrent: \`${general.gitStrategy}\``, }, { tag: "action", @@ -251,7 +246,7 @@ export function buildLarkSettingsDetailCard(params: { }, { tag: "markdown", - content: "Auto update", + content: `Auto update\nCurrent: \`${boolText(general.autoUpdate)}\``, }, { tag: "action", @@ -358,18 +353,11 @@ export function buildLarkSettingsDetailCard(params: { : []), { tag: "markdown", - content: [ - `- Channel: \`${channelId}\``, - `- Provider: \`${provider}\``, - `- Model: \`${model}\``, - `- Working directory: \`${cwd}\``, - `- Base branch: \`${baseBranch}\``, - `- Channel system message: ${systemMessage}`, - ].join("\n"), + content: "Coding Agent", }, { tag: "markdown", - content: "Provider", + content: `Provider\nCurrent: \`${provider}\``, }, { tag: "action", @@ -387,7 +375,7 @@ export function buildLarkSettingsDetailCard(params: { }, { tag: "markdown", - content: "Model", + content: `Model\nCurrent: \`${model}\``, }, { tag: "action", @@ -407,7 +395,11 @@ export function buildLarkSettingsDetailCard(params: { }, { tag: "markdown", - content: "Working directory", + content: "Execution", + }, + { + tag: "markdown", + content: `Working directory\nCurrent: \`${cwd}\``, }, { tag: "input", @@ -417,7 +409,7 @@ export function buildLarkSettingsDetailCard(params: { }, { tag: "markdown", - content: "Base branch", + content: `Base branch\nCurrent: \`${baseBranch}\``, }, { tag: "input", @@ -427,7 +419,7 @@ export function buildLarkSettingsDetailCard(params: { }, { tag: "markdown", - content: "Channel system message", + content: `System message\nCurrent: ${systemMessage}`, }, { tag: "input", From 2c33060f36f9bcb2282bd179bbf45753e1d586eb Mon Sep 17 00:00:00 2001 From: Joshua Chittick Date: Tue, 24 Feb 2026 14:01:03 +0000 Subject: [PATCH 6/6] fix: keep channel/github fields visible in Lark cards Replace unsupported input components with select actions for Channel and GitHub settings so fields render reliably in current Lark clients. --- packages/ims/lark/settings.ts | 150 ++++++++++++++++++++++++++-------- 1 file changed, 117 insertions(+), 33 deletions(-) diff --git a/packages/ims/lark/settings.ts b/packages/ims/lark/settings.ts index c940d5f..0f06a50 100644 --- a/packages/ims/lark/settings.ts +++ b/packages/ims/lark/settings.ts @@ -325,6 +325,36 @@ export function buildLarkSettingsDetailCard(params: { text: { tag: "plain_text", content: item }, value: item, })); + const workingDirectoryOptions = Array.from(new Set([ + cwd === "(not set)" ? "" : cwd, + "/root/ode-new", + "/root", + "/", + ])).map((item) => ({ + text: { tag: "plain_text", content: item || "(empty)" }, + value: item, + })); + const baseBranchOptions = Array.from(new Set([ + baseBranch, + "main", + "master", + "develop", + "dev", + ])).map((item) => ({ + text: { tag: "plain_text", content: item || "main" }, + value: item || "main", + })); + const channelSystemMessageOptions = [ + { text: "(empty)", value: "" }, + { text: "concise", value: "Please keep responses concise and actionable." }, + { text: "detailed", value: "Please provide detailed reasoning and include implementation notes." }, + { text: "current", value: systemMessage === "(none)" ? "" : systemMessage }, + ] + .filter((item, idx, all) => all.findIndex((x) => x.value === item.value) === idx) + .map((item) => ({ + text: { tag: "plain_text", content: item.text }, + value: item.value, + })); const settingsBaseValue = { action: "set_channel_settings", channelId, @@ -402,30 +432,54 @@ export function buildLarkSettingsDetailCard(params: { content: `Working directory\nCurrent: \`${cwd}\``, }, { - tag: "input", - name: "workingDirectory", - placeholder: { tag: "plain_text", content: "Enter working directory" }, - value: cwd === "(not set)" ? "" : cwd, + tag: "action", + actions: [ + { + tag: "select_static", + placeholder: { tag: "plain_text", content: "Select working directory" }, + options: workingDirectoryOptions, + value: { + ...settingsBaseValue, + field: "workingDirectory", + }, + }, + ], }, { tag: "markdown", content: `Base branch\nCurrent: \`${baseBranch}\``, }, { - tag: "input", - name: "baseBranch", - placeholder: { tag: "plain_text", content: "Enter base branch" }, - value: baseBranch, + tag: "action", + actions: [ + { + tag: "select_static", + placeholder: { tag: "plain_text", content: "Select base branch" }, + options: baseBranchOptions, + value: { + ...settingsBaseValue, + field: "baseBranch", + }, + }, + ], }, { tag: "markdown", content: `System message\nCurrent: ${systemMessage}`, }, { - tag: "input", - name: "channelSystemMessage", - placeholder: { tag: "plain_text", content: "Enter channel system message" }, - value: systemMessage === "(none)" ? "" : systemMessage, + tag: "action", + actions: [ + { + tag: "select_static", + placeholder: { tag: "plain_text", content: "Select system message" }, + options: channelSystemMessageOptions, + value: { + ...settingsBaseValue, + field: "channelSystemMessage", + }, + }, + ], }, { tag: "action", @@ -486,42 +540,72 @@ export function buildLarkSettingsDetailCard(params: { : []), { tag: "markdown", - content: [ - `- User: \`${userId}\``, - `- Token: \`${maskedToken(github?.token)}\``, - `- Git name: \`${github?.gitName || "(not set)"}\``, - `- Git email: \`${github?.gitEmail || "(not set)"}\``, - ].join("\n"), + content: "GitHub", }, { tag: "markdown", - content: "GitHub token", + content: `Token\nCurrent: \`${maskedToken(github?.token)}\``, }, { - tag: "input", - name: "githubToken", - placeholder: { tag: "plain_text", content: "Enter GitHub token" }, - value: githubToken, + tag: "action", + actions: [ + { + tag: "select_static", + placeholder: { tag: "plain_text", content: "Token action" }, + options: [ + { text: { tag: "plain_text", content: "Keep current" }, value: githubToken }, + { text: { tag: "plain_text", content: "Clear token" }, value: "" }, + ], + value: { + ...githubBaseValue, + field: "githubToken", + }, + }, + ], }, { tag: "markdown", - content: "Git name", + content: `Git name\nCurrent: \`${github?.gitName || "(not set)"}\``, }, { - tag: "input", - name: "githubName", - placeholder: { tag: "plain_text", content: "Enter git name" }, - value: githubName, + tag: "action", + actions: [ + { + tag: "select_static", + placeholder: { tag: "plain_text", content: "Select git name" }, + options: [ + { text: { tag: "plain_text", content: githubName || "(not set)" }, value: githubName }, + { text: { tag: "plain_text", content: "LIU9293" }, value: "LIU9293" }, + { text: { tag: "plain_text", content: "(empty)" }, value: "" }, + ], + value: { + ...githubBaseValue, + field: "githubName", + }, + }, + ], }, { tag: "markdown", - content: "Git email", + content: `Git email\nCurrent: \`${github?.gitEmail || "(not set)"}\``, }, { - tag: "input", - name: "githubEmail", - placeholder: { tag: "plain_text", content: "Enter git email" }, - value: githubEmail, + tag: "action", + actions: [ + { + tag: "select_static", + placeholder: { tag: "plain_text", content: "Select git email" }, + options: [ + { text: { tag: "plain_text", content: githubEmail || "(not set)" }, value: githubEmail }, + { text: { tag: "plain_text", content: "mylock.kai@gmail.com" }, value: "mylock.kai@gmail.com" }, + { text: { tag: "plain_text", content: "(empty)" }, value: "" }, + ], + value: { + ...githubBaseValue, + field: "githubEmail", + }, + }, + ], }, { tag: "action",