mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 12:18:59 +00:00
feat(teams): granular progress notifications with direct/leader mode
- Replace progress_notifications toggle with granular config: dispatched (on), progress (on), failed (on) + delivery mode - Direct mode: outbound to channel, no AI processing - Leader mode: inject into leader session with NO-ACTION instructions - Add consumer.team-notify subscriber for event forwarding - Enrich TeamTaskEventPayload with TaskNumber, ProgressPercent/Step - Add auto-status system prompt section - UI: card-select for delivery mode (Zap/Bot icons), 3 toggles
This commit is contained in:
1 parent
2c5ae04c01
commit
eee79d111e
8 files changed
+311
-82
No files matched your search
+106
@@ -510,6 +510,112 @@ func runGateway() {
|
||||
slog.Info("team task event subscriber registered")
|
||||
}
|
||||
|
||||
// Team progress notification subscriber — forwards task events to chat channels.
|
||||
// Reads team.settings.notifications config; direct mode sends outbound, leader mode
|
||||
// injects into leader agent session.
|
||||
if pgStores.Teams != nil {
|
||||
notifyTeamStore := pgStores.Teams
|
||||
notifyAgentStore := pgStores.Agents
|
||||
msgBus.Subscribe("consumer.team-notify", func(evt bus.Event) {
|
||||
payload, ok := evt.Payload.(protocol.TeamTaskEventPayload)
|
||||
if !ok || payload.TeamID == "" || payload.Channel == "" {
|
||||
return
|
||||
}
|
||||
// Only forward assigned/failed events (completed handled by announce-back).
|
||||
var notifyType string
|
||||
switch evt.Name {
|
||||
case protocol.EventTeamTaskAssigned:
|
||||
notifyType = "dispatched"
|
||||
case protocol.EventTeamTaskFailed:
|
||||
notifyType = "failed"
|
||||
case protocol.EventTeamTaskProgress:
|
||||
notifyType = "progress"
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
teamUUID, err := uuid.Parse(payload.TeamID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
team, err := notifyTeamStore.GetTeam(context.Background(), teamUUID)
|
||||
if err != nil || team == nil {
|
||||
return
|
||||
}
|
||||
cfg := tools.ParseTeamNotifyConfig(team.Settings)
|
||||
|
||||
// Check if this notification type is enabled.
|
||||
switch notifyType {
|
||||
case "dispatched":
|
||||
if !cfg.Dispatched {
|
||||
return
|
||||
}
|
||||
case "failed":
|
||||
if !cfg.Failed {
|
||||
return
|
||||
}
|
||||
case "progress":
|
||||
if !cfg.Progress {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Skip internal channels.
|
||||
if payload.Channel == tools.ChannelSystem || payload.Channel == tools.ChannelDelegate {
|
||||
return
|
||||
}
|
||||
|
||||
// Build notification message.
|
||||
var content string
|
||||
agentName := payload.OwnerAgentKey
|
||||
if payload.OwnerDisplayName != "" {
|
||||
agentName = payload.OwnerDisplayName
|
||||
}
|
||||
switch notifyType {
|
||||
case "dispatched":
|
||||
content = fmt.Sprintf("📋 Task #%d \"%s\" → assigned to %s", payload.TaskNumber, payload.Subject, agentName)
|
||||
case "progress":
|
||||
content = fmt.Sprintf("⏳ Task #%d: %d%% — %s", payload.TaskNumber, payload.ProgressPercent, payload.ProgressStep)
|
||||
case "failed":
|
||||
reason := payload.Reason
|
||||
if len(reason) > 200 {
|
||||
reason = reason[:200] + "..."
|
||||
}
|
||||
content = fmt.Sprintf("❌ Task #%d \"%s\" failed: %s", payload.TaskNumber, payload.Subject, reason)
|
||||
}
|
||||
|
||||
if cfg.Mode == "leader" {
|
||||
// Route through leader agent — model reformulates.
|
||||
leadAgent := ""
|
||||
if notifyAgentStore != nil {
|
||||
if la, err := notifyAgentStore.GetByID(context.Background(), team.LeadAgentID); err == nil {
|
||||
leadAgent = la.AgentKey
|
||||
}
|
||||
}
|
||||
if leadAgent == "" {
|
||||
return
|
||||
}
|
||||
leaderContent := fmt.Sprintf("[Auto-status — relay to user, NO task actions]\n%s\n\nBriefly inform the user. Do NOT create, retry, reassign, or modify any tasks.", content)
|
||||
msgBus.TryPublishInbound(bus.InboundMessage{
|
||||
Channel: payload.Channel,
|
||||
SenderID: "notification:progress",
|
||||
ChatID: payload.ChatID,
|
||||
AgentID: leadAgent,
|
||||
UserID: payload.UserID,
|
||||
Content: leaderContent,
|
||||
})
|
||||
} else {
|
||||
// Direct mode — send outbound directly to channel.
|
||||
msgBus.PublishOutbound(bus.OutboundMessage{
|
||||
Channel: payload.Channel,
|
||||
ChatID: payload.ChatID,
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
})
|
||||
slog.Info("team progress notification subscriber registered")
|
||||
}
|
||||
|
||||
// Setup graceful shutdown
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
@@ -370,5 +370,10 @@ func buildTeamWorkspaceSection(teamWsPath string) []string {
|
||||
"- Your default workspace (for relative paths) is your personal workspace",
|
||||
"- To delete a team file, use write_file with empty content",
|
||||
"",
|
||||
"## Auto-Status Updates",
|
||||
"You may receive [Auto-status] messages about team task progress.",
|
||||
"These are informational — simply relay the update to the user naturally.",
|
||||
"Do NOT create, retry, reassign, or modify tasks based on these updates.",
|
||||
"",
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,8 @@ type teamAccessSettings struct {
|
||||
DenyUserIDs []string `json:"deny_user_ids"`
|
||||
AllowChannels []string `json:"allow_channels"`
|
||||
DenyChannels []string `json:"deny_channels"`
|
||||
ProgressNotifications *bool `json:"progress_notifications,omitempty"`
|
||||
FollowupIntervalMins *int `json:"followup_interval_minutes,omitempty"`
|
||||
Notifications *TeamNotifyConfig `json:"notifications,omitempty"`
|
||||
FollowupIntervalMins *int `json:"followup_interval_minutes,omitempty"`
|
||||
FollowupMaxReminders *int `json:"followup_max_reminders,omitempty"`
|
||||
EscalationMode string `json:"escalation_mode,omitempty"`
|
||||
EscalationActions []string `json:"escalation_actions,omitempty"`
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package tools
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// TeamNotifyConfig controls which team task events are forwarded to chat channels.
|
||||
type TeamNotifyConfig struct {
|
||||
Dispatched bool `json:"dispatched"` // task assigned to member
|
||||
Progress bool `json:"progress"` // member updates progress
|
||||
Failed bool `json:"failed"` // task failed
|
||||
Mode string `json:"mode"` // "direct" (outbound) or "leader" (through leader agent)
|
||||
}
|
||||
|
||||
// DefaultTeamNotifyConfig returns the default notification config.
|
||||
func DefaultTeamNotifyConfig() TeamNotifyConfig {
|
||||
return TeamNotifyConfig{
|
||||
Dispatched: true,
|
||||
Progress: true,
|
||||
Failed: true,
|
||||
Mode: "direct",
|
||||
}
|
||||
}
|
||||
|
||||
// ParseTeamNotifyConfig extracts notification config from team settings JSON.
|
||||
// Returns defaults for missing/invalid settings.
|
||||
func ParseTeamNotifyConfig(settings json.RawMessage) TeamNotifyConfig {
|
||||
cfg := DefaultTeamNotifyConfig()
|
||||
if len(settings) == 0 {
|
||||
return cfg
|
||||
}
|
||||
var s struct {
|
||||
Notifications *TeamNotifyConfig `json:"notifications"`
|
||||
}
|
||||
if json.Unmarshal(settings, &s) != nil || s.Notifications == nil {
|
||||
return cfg
|
||||
}
|
||||
n := s.Notifications
|
||||
cfg.Dispatched = n.Dispatched
|
||||
cfg.Progress = n.Progress
|
||||
cfg.Failed = n.Failed
|
||||
if n.Mode == "leader" {
|
||||
cfg.Mode = "leader"
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
@@ -237,8 +237,12 @@ func (m *TeamToolManager) DispatchUnblockedTasks(ctx context.Context, teamID uui
|
||||
m.broadcastTeamEvent(protocol.EventTeamTaskAssigned, protocol.TeamTaskEventPayload{
|
||||
TeamID: teamID.String(),
|
||||
TaskID: task.ID.String(),
|
||||
TaskNumber: task.TaskNumber,
|
||||
Subject: task.Subject,
|
||||
Status: store.TeamTaskStatusInProgress,
|
||||
OwnerAgentKey: m.agentKeyFromID(ctx, *task.OwnerAgentID),
|
||||
Channel: task.Channel,
|
||||
ChatID: task.ChatID,
|
||||
Timestamp: time.Now().UTC().Format("2006-01-02T15:04:05Z"),
|
||||
ActorType: "system",
|
||||
ActorID: "dispatch_unblocked",
|
||||
|
||||
@@ -107,6 +107,7 @@ type QualityGateRetryPayload struct {
|
||||
type TeamTaskEventPayload struct {
|
||||
TeamID string `json:"team_id"`
|
||||
TaskID string `json:"task_id"`
|
||||
TaskNumber int `json:"task_number,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Status string `json:"status"`
|
||||
OwnerAgentKey string `json:"owner_agent_key,omitempty"`
|
||||
@@ -117,6 +118,10 @@ type TeamTaskEventPayload struct {
|
||||
ChatID string `json:"chat_id"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
|
||||
// Progress (for team.task.progress events).
|
||||
ProgressPercent int `json:"progress_percent,omitempty"`
|
||||
ProgressStep string `json:"progress_step,omitempty"`
|
||||
|
||||
// Actor info for audit trail (recorded to team_task_events by subscriber).
|
||||
ActorType string `json:"actor_type,omitempty"` // "agent", "human", "system"
|
||||
ActorID string `json:"actor_id,omitempty"` // agent key, user ID, or system identifier
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { X, Save, Check, Bell, ShieldAlert, Clock, Info, FolderLock, FolderSync } from "lucide-react";
|
||||
import { X, Save, Check, Bell, ShieldAlert, Clock, Info, FolderLock, FolderSync, Zap, Bot } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CHANNEL_TYPES } from "@/constants/channels";
|
||||
import type { TeamData, TeamAccessSettings, EscalationMode, EscalationAction } from "@/types/team";
|
||||
@@ -71,7 +71,11 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
|
||||
const [denyUserIds, setDenyUserIds] = useState<string[]>(initial.deny_user_ids ?? []);
|
||||
const [allowChannels, setAllowChannels] = useState<string[]>(initial.allow_channels ?? []);
|
||||
const [denyChannels, setDenyChannels] = useState<string[]>(initial.deny_channels ?? []);
|
||||
const [progressNotifications, setProgressNotifications] = useState(initial.progress_notifications ?? false);
|
||||
const initNotify = initial.notifications ?? {};
|
||||
const [notifyDispatched, setNotifyDispatched] = useState(initNotify.dispatched ?? true);
|
||||
const [notifyProgress, setNotifyProgress] = useState(initNotify.progress ?? true);
|
||||
const [notifyFailed, setNotifyFailed] = useState(initNotify.failed ?? true);
|
||||
const [notifyMode, setNotifyMode] = useState<"direct" | "leader">(initNotify.mode ?? "direct");
|
||||
const [escalationMode, setEscalationMode] = useState<EscalationMode | "">(initial.escalation_mode ?? "");
|
||||
const [escalationActions, setEscalationActions] = useState<EscalationAction[]>(initial.escalation_actions ?? []);
|
||||
const [followupInterval, setFollowupInterval] = useState<number>(initial.followup_interval_minutes ?? 30);
|
||||
@@ -97,7 +101,11 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
|
||||
setDenyUserIds(s.deny_user_ids ?? []);
|
||||
setAllowChannels(s.allow_channels ?? []);
|
||||
setDenyChannels(s.deny_channels ?? []);
|
||||
setProgressNotifications(s.progress_notifications ?? false);
|
||||
const sn = s.notifications ?? {};
|
||||
setNotifyDispatched(sn.dispatched ?? true);
|
||||
setNotifyProgress(sn.progress ?? true);
|
||||
setNotifyFailed(sn.failed ?? true);
|
||||
setNotifyMode(sn.mode ?? "direct");
|
||||
setEscalationMode(s.escalation_mode ?? "");
|
||||
setEscalationActions(s.escalation_actions ?? []);
|
||||
setFollowupInterval(s.followup_interval_minutes ?? 30);
|
||||
@@ -117,14 +125,19 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
|
||||
if (denyUserIds.length > 0) settings.deny_user_ids = denyUserIds;
|
||||
if (allowChannels.length > 0) settings.allow_channels = allowChannels;
|
||||
if (denyChannels.length > 0) settings.deny_channels = denyChannels;
|
||||
if (progressNotifications) settings.progress_notifications = true;
|
||||
settings.notifications = {
|
||||
dispatched: notifyDispatched,
|
||||
progress: notifyProgress,
|
||||
failed: notifyFailed,
|
||||
mode: notifyMode,
|
||||
};
|
||||
if (escalationMode) {
|
||||
settings.escalation_mode = escalationMode;
|
||||
if (escalationActions.length > 0) settings.escalation_actions = escalationActions;
|
||||
}
|
||||
if (followupInterval !== 30) settings.followup_interval_minutes = followupInterval;
|
||||
if (followupMaxReminders !== 0) settings.followup_max_reminders = followupMaxReminders;
|
||||
if (workspaceScope === "shared") settings.workspace_scope = "shared";
|
||||
settings.workspace_scope = workspaceScope || "isolated";
|
||||
if (version >= 2) settings.version = version;
|
||||
await updateTeamSettings(teamId, settings);
|
||||
setSaved(true);
|
||||
@@ -135,7 +148,7 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [teamId, version, allowUserIds, denyUserIds, allowChannels, denyChannels, progressNotifications, escalationMode, escalationActions, followupInterval, followupMaxReminders, workspaceScope, updateTeamSettings, onSaved, t]);
|
||||
}, [teamId, version, allowUserIds, denyUserIds, allowChannels, denyChannels, notifyDispatched, notifyProgress, notifyFailed, notifyMode, escalationMode, escalationActions, followupInterval, followupMaxReminders, workspaceScope, updateTeamSettings, onSaved, t]);
|
||||
|
||||
const userOptions = knownUsers.map((u) => ({ value: u, label: u }));
|
||||
const channelOptions = CHANNEL_TYPES.map((c) => ({ value: c.value, label: c.label }));
|
||||
@@ -192,87 +205,70 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Access Control */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">{t("settings.userAccessControl")}</h3>
|
||||
<div className="space-y-3 rounded-lg border p-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">{t("settings.allowedUsers")}</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.allowedUsersHint")}
|
||||
</p>
|
||||
<MultiSelect
|
||||
options={userOptions}
|
||||
selected={allowUserIds}
|
||||
onChange={setAllowUserIds}
|
||||
placeholder={t("settings.searchUsers")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">{t("settings.deniedUsers")}</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.deniedUsersHint")}
|
||||
</p>
|
||||
<MultiSelect
|
||||
options={userOptions}
|
||||
selected={denyUserIds}
|
||||
onChange={setDenyUserIds}
|
||||
placeholder={t("settings.searchUsers")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel Restrictions */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">{t("settings.channelRestrictions")}</h3>
|
||||
<div className="space-y-3 rounded-lg border p-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">{t("settings.allowedChannels")}</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.allowedChannelsHint")}
|
||||
</p>
|
||||
<MultiSelect
|
||||
options={channelOptions}
|
||||
selected={allowChannels}
|
||||
onChange={setAllowChannels}
|
||||
placeholder={t("settings.selectChannel")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">{t("settings.deniedChannels")}</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.deniedChannelsHint")}
|
||||
</p>
|
||||
<MultiSelect
|
||||
options={channelOptions}
|
||||
selected={denyChannels}
|
||||
onChange={setDenyChannels}
|
||||
placeholder={t("settings.selectChannel")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notifications */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">{t("settings.notifications")}</h3>
|
||||
<div className="rounded-lg border bg-gradient-to-r from-blue-500/5 to-purple-500/5 p-4">
|
||||
<div className="rounded-lg border bg-gradient-to-r from-blue-500/5 to-purple-500/5 p-4 space-y-3">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="rounded-lg bg-blue-500/10 p-2.5 text-blue-600 dark:text-blue-400">
|
||||
<Bell className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold">{t("settings.progressNotifications")}</span>
|
||||
<Switch
|
||||
checked={progressNotifications}
|
||||
onCheckedChange={setProgressNotifications}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-semibold">{t("settings.notifyDispatched")}</span>
|
||||
<p className="text-xs text-muted-foreground">{t("settings.notifyDispatchedHint")}</p>
|
||||
</div>
|
||||
<Switch checked={notifyDispatched} onCheckedChange={setNotifyDispatched} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm font-semibold">{t("settings.notifyProgress")}</span>
|
||||
<p className="text-xs text-muted-foreground">{t("settings.notifyProgressHint")}</p>
|
||||
</div>
|
||||
<Switch checked={notifyProgress} onCheckedChange={setNotifyProgress} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm font-semibold">{t("settings.notifyFailed")}</span>
|
||||
<p className="text-xs text-muted-foreground">{t("settings.notifyFailedHint")}</p>
|
||||
</div>
|
||||
<Switch checked={notifyFailed} onCheckedChange={setNotifyFailed} />
|
||||
</div>
|
||||
<div className="border-t pt-3 space-y-2">
|
||||
<span className="text-sm font-semibold">{t("settings.notifyMode")}</span>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{([
|
||||
{ value: "direct" as const, Icon: Zap, labelKey: "notifyModeDirect", descKey: "notifyModeDirectDesc" },
|
||||
{ value: "leader" as const, Icon: Bot, labelKey: "notifyModeLeader", descKey: "notifyModeLeaderDesc" },
|
||||
]).map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setNotifyMode(opt.value)}
|
||||
className={
|
||||
"flex items-start gap-3 rounded-lg border p-3 text-left transition-colors cursor-pointer " +
|
||||
(notifyMode === opt.value
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50")
|
||||
}
|
||||
>
|
||||
<opt.Icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t(`settings.${opt.labelKey}`)}</div>
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
{t(`settings.${opt.descKey}`)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{notifyMode === "leader" && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
⚠️ {t("settings.notifyModeLeaderWarning")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t("settings.progressNotificationsHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -303,7 +299,7 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
|
||||
type="button"
|
||||
onClick={() => setWorkspaceScope(opt.value)}
|
||||
className={
|
||||
"flex items-start gap-3 rounded-lg border p-3 text-left transition-colors " +
|
||||
"flex items-start gap-3 rounded-lg border p-3 text-left transition-colors cursor-pointer " +
|
||||
(workspaceScope === opt.value
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50")
|
||||
@@ -387,6 +383,68 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{/* User Access Control */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">{t("settings.userAccessControl")}</h3>
|
||||
<div className="space-y-3 rounded-lg border p-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">{t("settings.allowedUsers")}</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.allowedUsersHint")}
|
||||
</p>
|
||||
<MultiSelect
|
||||
options={userOptions}
|
||||
selected={allowUserIds}
|
||||
onChange={setAllowUserIds}
|
||||
placeholder={t("settings.searchUsers")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">{t("settings.deniedUsers")}</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.deniedUsersHint")}
|
||||
</p>
|
||||
<MultiSelect
|
||||
options={userOptions}
|
||||
selected={denyUserIds}
|
||||
onChange={setDenyUserIds}
|
||||
placeholder={t("settings.searchUsers")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel Restrictions */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium">{t("settings.channelRestrictions")}</h3>
|
||||
<div className="space-y-3 rounded-lg border p-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">{t("settings.allowedChannels")}</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.allowedChannelsHint")}
|
||||
</p>
|
||||
<MultiSelect
|
||||
options={channelOptions}
|
||||
selected={allowChannels}
|
||||
onChange={setAllowChannels}
|
||||
placeholder={t("settings.selectChannel")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">{t("settings.deniedChannels")}</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.deniedChannelsHint")}
|
||||
</p>
|
||||
<MultiSelect
|
||||
options={channelOptions}
|
||||
selected={denyChannels}
|
||||
onChange={setDenyChannels}
|
||||
placeholder={t("settings.selectChannel")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save button */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button onClick={handleSave} disabled={saving} className="gap-2">
|
||||
|
||||
@@ -5,13 +5,20 @@ export type EscalationMode = "auto" | "review" | "reject";
|
||||
export const ESCALATION_ACTIONS = ["pin", "unpin", "tag", "set_template", "delete"] as const;
|
||||
export type EscalationAction = (typeof ESCALATION_ACTIONS)[number];
|
||||
|
||||
export interface TeamNotifyConfig {
|
||||
dispatched?: boolean;
|
||||
progress?: boolean;
|
||||
failed?: boolean;
|
||||
mode?: "direct" | "leader";
|
||||
}
|
||||
|
||||
export interface TeamAccessSettings {
|
||||
version?: number;
|
||||
allow_user_ids?: string[];
|
||||
deny_user_ids?: string[];
|
||||
allow_channels?: string[];
|
||||
deny_channels?: string[];
|
||||
progress_notifications?: boolean;
|
||||
notifications?: TeamNotifyConfig;
|
||||
escalation_mode?: EscalationMode;
|
||||
escalation_actions?: EscalationAction[];
|
||||
followup_interval_minutes?: number;
|
||||
|
||||
Reference in new issue
Block a user