Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export {
getSlackTargetChannels,
getDefaultCwd,
getGitHubInfoForUser,
getUserGeneralSettings,
resolveChannelCwd,
getChannelSystemMessage,
getChannelBaseBranch,
Expand All @@ -30,6 +31,7 @@ export {
setChannelBaseBranch,
setChannelSystemMessage,
setGitHubInfoForUser,
setUserGeneralSettings,
clearGitHubInfoForUser,
setChannelModel,
setChannelAgentProvider,
Expand All @@ -41,6 +43,7 @@ export {
type UpdateConfig,
type ChannelDetail,
type UserConfig,
type UserGeneralSettings,
} from "./local/ode";

export {
Expand Down
28 changes: 28 additions & 0 deletions packages/config/local/ode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 ?? {}) };
Expand Down
46 changes: 42 additions & 4 deletions packages/ims/slack/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,15 +233,19 @@ 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());
}

function isGitHubCommand(text: string): boolean {
return /^\/gh\b/i.test(text.trim());
}

async function postSettingsLauncher(
async function postChannelSettingsLauncher(
channelId: string,
userId: string,
client: WebClient
Expand Down Expand Up @@ -273,6 +277,38 @@ async function postSettingsLauncher(
});
}

async function postGeneralSettingsLauncher(
channelId: string,
userId: string,
client: WebClient
): Promise<void> {
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,
Expand Down Expand Up @@ -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),
Expand Down
144 changes: 144 additions & 0 deletions packages/ims/slack/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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"];

Expand All @@ -54,6 +64,17 @@ const AGENT_PROVIDER_LABELS: Record<AgentProvider, string> = {
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";
Expand Down Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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();
Expand Down
11 changes: 7 additions & 4 deletions packages/ims/slack/message-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
postSettingsLauncher: (channelId: string, userId: string, client: any) => Promise<void>;
postChannelSettingsLauncher: (channelId: string, userId: string, client: any) => Promise<void>;
postGeneralSettingsLauncher: (channelId: string, userId: string, client: any) => Promise<void>;
describeSettingsIssues: (channelId: string) => string[];
getChannelAgentProvider: (channelId: string) => "opencode" | "claudecode" | "codex" | "kimi" | "kiro" | "qwen";
handleStopCommand: (channelId: string, threadId: string) => Promise<boolean>;
Expand Down Expand Up @@ -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;
}

Expand All @@ -132,7 +134,8 @@ async function maybeHandleLauncherCommand(params: {
launch: (channelId: string, userId: string, client: any) => Promise<void>;
}> = [
{ 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));
Expand Down
8 changes: 4 additions & 4 deletions packages/web-ui/src/routes/local-setting/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "";

Expand All @@ -19,7 +19,7 @@
? "agents"
: normalizedPathname.startsWith("/local-setting/slack-bot")
? "slack"
: "profile";
: "general";

$: selectedWorkspace = getSelectedWorkspace($page.params.workspaceName ?? "", $localSettingStore.config.workspaces);

Expand Down Expand Up @@ -91,8 +91,8 @@

<div class="layout">
<aside class="sidebar card">
<button class="nav-item {activeSection === 'profile' ? 'active' : ''}" on:click={() => goto('/local-setting')}>
Profile
<button class="nav-item {activeSection === 'general' ? 'active' : ''}" on:click={() => goto('/local-setting')}>
General
</button>
<button class="nav-item {activeSection === 'agents' ? 'active' : ''}" on:click={() => goto('/local-setting/agents')}>
Agents
Expand Down
Loading