diff --git a/packages/config/index.ts b/packages/config/index.ts index 4cc19a2..b3b0528 100644 --- a/packages/config/index.ts +++ b/packages/config/index.ts @@ -22,6 +22,7 @@ export { getSlackTargetChannels, getDefaultCwd, getGitHubInfoForUser, + getUserGeneralSettings, resolveChannelCwd, getChannelSystemMessage, getChannelBaseBranch, @@ -30,6 +31,7 @@ export { setChannelBaseBranch, setChannelSystemMessage, setGitHubInfoForUser, + setUserGeneralSettings, clearGitHubInfoForUser, setChannelModel, setChannelAgentProvider, @@ -41,6 +43,7 @@ export { type UpdateConfig, type ChannelDetail, type UserConfig, + type UserGeneralSettings, } from "./local/ode"; export { diff --git a/packages/config/local/ode.ts b/packages/config/local/ode.ts index e63b4ea..a4bc282 100644 --- a/packages/config/local/ode.ts +++ b/packages/config/local/ode.ts @@ -446,6 +446,11 @@ export type GitHubInfo = { gitEmail?: string; }; +export type UserGeneralSettings = { + defaultStatusMessageFormat: "minimum" | "medium" | "aggressive"; + gitStrategy: "default" | "worktree"; +}; + export function getGitHubInfoForUser(userId: string): GitHubInfo | null { const info = loadOdeConfig().githubInfos?.[userId]; if (!info) return null; @@ -456,6 +461,29 @@ export function getGitHubInfoForUser(userId: string): GitHubInfo | null { return { token, gitName, gitEmail }; } +export function getUserGeneralSettings(): UserGeneralSettings { + const user = loadOdeConfig().user; + return { + defaultStatusMessageFormat: + user.defaultStatusMessageFormat === "minimum" || user.defaultStatusMessageFormat === "aggressive" + ? user.defaultStatusMessageFormat + : "medium", + gitStrategy: user.gitStrategy === "default" ? "default" : "worktree", + }; +} + +export function setUserGeneralSettings(settings: UserGeneralSettings): void { + const config = loadOdeConfig(); + saveOdeConfig({ + ...config, + user: { + ...config.user, + defaultStatusMessageFormat: settings.defaultStatusMessageFormat, + gitStrategy: settings.gitStrategy, + }, + }); +} + export function setGitHubInfoForUser(userId: string, info: GitHubInfo): void { const config = loadOdeConfig(); const githubInfos = { ...(config.githubInfos ?? {}) }; diff --git a/packages/ims/slack/client.ts b/packages/ims/slack/client.ts index 0fd6eb9..d659d08 100644 --- a/packages/ims/slack/client.ts +++ b/packages/ims/slack/client.ts @@ -233,7 +233,11 @@ function describeSettingsIssues(channelId: string): string[] { return issues; } -function isSettingsCommand(text: string): boolean { +function isChannelSettingsCommand(text: string): boolean { + return /^\/channel\b/i.test(text.trim()); +} + +function isGeneralSettingsCommand(text: string): boolean { return /^\/setting\b/i.test(text.trim()); } @@ -241,7 +245,7 @@ function isGitHubCommand(text: string): boolean { return /^\/gh\b/i.test(text.trim()); } -async function postSettingsLauncher( +async function postChannelSettingsLauncher( channelId: string, userId: string, client: WebClient @@ -273,6 +277,38 @@ async function postSettingsLauncher( }); } +async function postGeneralSettingsLauncher( + channelId: string, + userId: string, + client: WebClient +): Promise { + await client.chat.postEphemeral({ + channel: channelId, + user: userId, + text: "Open general settings", + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: "Open general settings for status message format and git strategy.", + }, + }, + { + type: "actions", + elements: [ + { + type: "button", + action_id: "open_general_settings_modal", + text: { type: "plain_text", text: "Open settings" }, + value: channelId, + }, + ], + }, + ], + }); +} + async function postGitHubLauncher( channelId: string, userId: string, @@ -554,9 +590,11 @@ export function setupMessageHandlers(): void { isThreadActive, markThreadActive, isGitHubCommand, - isSettingsCommand, + isChannelSettingsCommand, + isGeneralSettingsCommand, postGitHubLauncher, - postSettingsLauncher, + postChannelSettingsLauncher, + postGeneralSettingsLauncher, describeSettingsIssues, getChannelAgentProvider, handleStopCommand: (channelId, threadId) => coreRuntime.handleStopCommand(channelId, threadId), diff --git a/packages/ims/slack/commands.ts b/packages/ims/slack/commands.ts index 30566d1..196fc55 100644 --- a/packages/ims/slack/commands.ts +++ b/packages/ims/slack/commands.ts @@ -16,12 +16,16 @@ import { getChannelSystemMessage, setChannelSystemMessage, setGitHubInfoForUser, + getUserGeneralSettings, + setUserGeneralSettings, } from "@/config"; import { startServer as startOpenCodeServer } from "@/agents/opencode"; import { startServer as startCodexServer } from "@/agents/codex"; const SETTINGS_LAUNCH_ACTION = "open_settings_modal"; const SETTINGS_MODAL_ID = "settings_modal"; +const GENERAL_SETTINGS_LAUNCH_ACTION = "open_general_settings_modal"; +const GENERAL_SETTINGS_MODAL_ID = "general_settings_modal"; const GITHUB_LAUNCH_ACTION = "open_github_token_modal"; const GITHUB_MODAL_ID = "github_token_modal"; const GITHUB_TOKEN_BLOCK = "github_token"; @@ -40,8 +44,14 @@ const BASE_BRANCH_BLOCK = "base_branch"; const BASE_BRANCH_ACTION = "base_branch_input"; const CHANNEL_SYSTEM_MESSAGE_BLOCK = "channel_system_message"; const CHANNEL_SYSTEM_MESSAGE_ACTION = "channel_system_message_input"; +const GENERAL_STATUS_MESSAGE_FORMAT_BLOCK = "general_status_message_format"; +const GENERAL_STATUS_MESSAGE_FORMAT_ACTION = "general_status_message_format_select"; +const GENERAL_GIT_STRATEGY_BLOCK = "general_git_strategy"; +const GENERAL_GIT_STRATEGY_ACTION = "general_git_strategy_select"; type AgentProvider = "opencode" | "claudecode" | "codex" | "kimi" | "kiro" | "qwen"; +type StatusMessageFormat = "aggressive" | "medium" | "minimum"; +type GitStrategy = "default" | "worktree"; const AGENT_PROVIDERS: AgentProvider[] = ["opencode", "claudecode", "codex", "kimi", "kiro", "qwen"]; @@ -54,6 +64,17 @@ const AGENT_PROVIDER_LABELS: Record = { qwen: "Qwen Code", }; +const STATUS_MESSAGE_FORMAT_OPTIONS: Array<{ label: string; value: StatusMessageFormat }> = [ + { label: "Aggressive", value: "aggressive" }, + { label: "Medium", value: "medium" }, + { label: "Minimum", value: "minimum" }, +]; + +const GIT_STRATEGY_OPTIONS: Array<{ label: string; value: GitStrategy }> = [ + { label: "Worktree", value: "worktree" }, + { label: "Default", value: "default" }, +]; + function parseAgentProvider(value: unknown): AgentProvider { if (typeof value !== "string") return "opencode"; return AGENT_PROVIDERS.includes(value as AgentProvider) ? value as AgentProvider : "opencode"; @@ -284,6 +305,66 @@ function buildGitHubTokenModal(params: { }; } +function buildGeneralSettingsModal(params: { + channelId: string; + statusMessageFormat: StatusMessageFormat; + gitStrategy: GitStrategy; +}) { + const { channelId, statusMessageFormat, gitStrategy } = params; + const statusMessageFormatOptions = STATUS_MESSAGE_FORMAT_OPTIONS.map((option) => ({ + text: { type: "plain_text" as const, text: option.label }, + value: option.value, + })); + const gitStrategyOptions = GIT_STRATEGY_OPTIONS.map((option) => ({ + text: { type: "plain_text" as const, text: option.label }, + value: option.value, + })); + + return { + type: "modal" as const, + callback_id: GENERAL_SETTINGS_MODAL_ID, + private_metadata: channelId, + title: { type: "plain_text" as const, text: "General Settings" }, + submit: { type: "plain_text" as const, text: "Save" }, + close: { type: "plain_text" as const, text: "Cancel" }, + blocks: [ + { + type: "section" as const, + text: { + type: "mrkdwn" as const, + text: "Configure default status message format and git strategy.", + }, + }, + { + type: "input" as const, + block_id: GENERAL_STATUS_MESSAGE_FORMAT_BLOCK, + label: { type: "plain_text" as const, text: "Status Message Format" }, + element: { + type: "static_select" as const, + action_id: GENERAL_STATUS_MESSAGE_FORMAT_ACTION, + options: statusMessageFormatOptions, + initial_option: + statusMessageFormatOptions.find((option) => option.value === statusMessageFormat) + ?? statusMessageFormatOptions[1], + }, + }, + { + type: "input" as const, + block_id: GENERAL_GIT_STRATEGY_BLOCK, + label: { type: "plain_text" as const, text: "Git Strategy" }, + element: { + type: "static_select" as const, + action_id: GENERAL_GIT_STRATEGY_ACTION, + options: gitStrategyOptions, + initial_option: + gitStrategyOptions.find((option) => option.value === gitStrategy) + ?? gitStrategyOptions[0], + }, + }, + ], + }; +} + export function setupInteractiveHandlers(): void { for (const slackApp of getApps()) { slackApp.action(SETTINGS_LAUNCH_ACTION, async ({ ack, body, client }) => { @@ -347,6 +428,25 @@ export function setupInteractiveHandlers(): void { }); }); + slackApp.action(GENERAL_SETTINGS_LAUNCH_ACTION, async ({ ack, body, client }) => { + await ack(); + + const channelId = (body as any).actions?.[0]?.value + ?? (body as any).channel?.id + ?? ""; + const generalSettings = getUserGeneralSettings(); + const view = buildGeneralSettingsModal({ + channelId, + statusMessageFormat: generalSettings.defaultStatusMessageFormat, + gitStrategy: generalSettings.gitStrategy, + }); + + await client.views.open({ + trigger_id: (body as any).trigger_id, + view, + }); + }); + slackApp.action(PROVIDER_ACTION, async ({ ack, body, client }) => { await ack(); @@ -528,6 +628,50 @@ export function setupInteractiveHandlers(): void { }); }); + slackApp.view(GENERAL_SETTINGS_MODAL_ID, async ({ ack, view, body, client }) => { + const values = view.state.values; + const selectedStatusMessageFormat = values?.[GENERAL_STATUS_MESSAGE_FORMAT_BLOCK]?.[GENERAL_STATUS_MESSAGE_FORMAT_ACTION]?.selected_option?.value; + const selectedGitStrategy = values?.[GENERAL_GIT_STRATEGY_BLOCK]?.[GENERAL_GIT_STRATEGY_ACTION]?.selected_option?.value; + + const statusMessageFormat: StatusMessageFormat = + selectedStatusMessageFormat === "aggressive" + || selectedStatusMessageFormat === "minimum" + || selectedStatusMessageFormat === "medium" + ? selectedStatusMessageFormat + : "medium"; + const gitStrategy: GitStrategy = selectedGitStrategy === "default" ? "default" : "worktree"; + + await ack(); + + try { + setUserGeneralSettings({ + defaultStatusMessageFormat: statusMessageFormat, + gitStrategy, + }); + } catch (err) { + const userId = (body as any).user?.id; + const channelId = view.private_metadata || (body as any).channel?.id; + if (userId && channelId) { + await client.chat.postEphemeral({ + channel: channelId, + user: userId, + text: `Failed to update general settings: ${err instanceof Error ? err.message : String(err)}`, + }); + } + return; + } + + const userId = (body as any).user?.id; + const channelId = view.private_metadata || (body as any).channel?.id; + if (userId && channelId) { + await client.chat.postEphemeral({ + channel: channelId, + user: userId, + text: "General settings updated.", + }); + } + }); + // Handle user choice button clicks (from Ode ask_user actions) slackApp.action(/^user_choice_\d+$/, async ({ ack, body, client }) => { await ack(); diff --git a/packages/ims/slack/message-router.ts b/packages/ims/slack/message-router.ts index 4fc1279..1051671 100644 --- a/packages/ims/slack/message-router.ts +++ b/packages/ims/slack/message-router.ts @@ -16,9 +16,11 @@ type RouterDeps = { isThreadActive: (channelId: string, threadId: string) => boolean; markThreadActive: (channelId: string, threadId: string) => void; isGitHubCommand: (text: string) => boolean; - isSettingsCommand: (text: string) => boolean; + isChannelSettingsCommand: (text: string) => boolean; + isGeneralSettingsCommand: (text: string) => boolean; postGitHubLauncher: (channelId: string, userId: string, client: any) => Promise; - postSettingsLauncher: (channelId: string, userId: string, client: any) => Promise; + postChannelSettingsLauncher: (channelId: string, userId: string, client: any) => Promise; + postGeneralSettingsLauncher: (channelId: string, userId: string, client: any) => Promise; describeSettingsIssues: (channelId: string) => string[]; getChannelAgentProvider: (channelId: string) => "opencode" | "claudecode" | "codex" | "kimi" | "kiro" | "qwen"; handleStopCommand: (channelId: string, threadId: string) => Promise; @@ -113,7 +115,7 @@ async function maybeNotifySettingsIssues( text: `Channel settings need attention:\n- ${settingsIssues.join("\n- ")}`, thread_ts: threadId, }); - await deps.postSettingsLauncher(channelId, userId, client); + await deps.postChannelSettingsLauncher(channelId, userId, client); return true; } @@ -132,7 +134,8 @@ async function maybeHandleLauncherCommand(params: { launch: (channelId: string, userId: string, client: any) => Promise; }> = [ { matches: deps.isGitHubCommand, launch: deps.postGitHubLauncher }, - { matches: deps.isSettingsCommand, launch: deps.postSettingsLauncher }, + { matches: deps.isChannelSettingsCommand, launch: deps.postChannelSettingsLauncher }, + { matches: deps.isGeneralSettingsCommand, launch: deps.postGeneralSettingsLauncher }, ]; const handler = commandHandlers.find((entry) => entry.matches(cleanText)); diff --git a/packages/web-ui/src/routes/local-setting/+layout.svelte b/packages/web-ui/src/routes/local-setting/+layout.svelte index 89ad76b..80b41fe 100644 --- a/packages/web-ui/src/routes/local-setting/+layout.svelte +++ b/packages/web-ui/src/routes/local-setting/+layout.svelte @@ -8,7 +8,7 @@ let pathname = "/local-setting"; let normalizedPathname = pathname; - let activeSection: "profile" | "agents" | "slack" = "profile"; + let activeSection: "general" | "agents" | "slack" = "general"; let pendingSlackAppToken = ""; let pendingSlackBotToken = ""; @@ -19,7 +19,7 @@ ? "agents" : normalizedPathname.startsWith("/local-setting/slack-bot") ? "slack" - : "profile"; + : "general"; $: selectedWorkspace = getSelectedWorkspace($page.params.workspaceName ?? "", $localSettingStore.config.workspaces); @@ -91,8 +91,8 @@