mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 14:16:08 +00:00
feat: configurable workspace sharing with per-agent DM/group/user controls
Add workspace_sharing config in other_config JSONB to control per-user workspace isolation. When enabled, users share the base workspace directory instead of isolated subfolders — configurable separately for DMs and groups, with a per-user allowlist override. Backend: WorkspaceSharingConfig struct, ParseWorkspaceSharing(), conditional isolation in loop.go/loop_history.go, 7 unit tests. Frontend: prominent always-visible config section with contact search combobox, sticky save bar layout fix, i18n (en/vi/zh).
This commit is contained in:
1 parent
a75e977dca
commit
25b24ebd50
15 files changed
+361
-7
No files matched your search
@@ -103,7 +103,10 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
l.userWorkspaces.Store(req.UserID, ws)
|
||||
cachedWs = ws
|
||||
}
|
||||
effectiveWorkspace := filepath.Join(cachedWs.(string), sanitizePathSegment(req.UserID))
|
||||
effectiveWorkspace := cachedWs.(string)
|
||||
if !l.shouldShareWorkspace(req.UserID, req.PeerKind) {
|
||||
effectiveWorkspace = filepath.Join(effectiveWorkspace, sanitizePathSegment(req.UserID))
|
||||
}
|
||||
if err := os.MkdirAll(effectiveWorkspace, 0755); err != nil {
|
||||
slog.Warn("failed to create user workspace directory", "workspace", effectiveWorkspace, "user", req.UserID, "error", err)
|
||||
}
|
||||
|
||||
@@ -66,11 +66,15 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s
|
||||
|
||||
// Per-user workspace: show the user's subdirectory in the system prompt.
|
||||
// Uses cached workspace from user_agent_profiles (includes channel isolation).
|
||||
// When workspace sharing is enabled, show the base workspace without user subfolder.
|
||||
promptWorkspace := l.workspace
|
||||
if l.agentUUID != uuid.Nil && userID != "" && l.workspace != "" {
|
||||
if cachedWs, ok := l.userWorkspaces.Load(userID); ok {
|
||||
promptWorkspace = filepath.Join(cachedWs.(string), sanitizePathSegment(userID))
|
||||
} else {
|
||||
promptWorkspace = cachedWs.(string)
|
||||
if !l.shouldShareWorkspace(userID, peerKind) {
|
||||
promptWorkspace = filepath.Join(promptWorkspace, sanitizePathSegment(userID))
|
||||
}
|
||||
} else if !l.shouldShareWorkspace(userID, peerKind) {
|
||||
promptWorkspace = filepath.Join(l.workspace, sanitizePathSegment(userID))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,8 @@ type Loop struct {
|
||||
contextWindow int
|
||||
maxIterations int
|
||||
maxToolCalls int
|
||||
workspace string
|
||||
workspace string
|
||||
workspaceSharing *store.WorkspaceSharingConfig
|
||||
|
||||
// Per-agent overrides from DB (nil = use global defaults)
|
||||
restrictToWs *bool
|
||||
@@ -153,7 +154,8 @@ type LoopConfig struct {
|
||||
ContextWindow int
|
||||
MaxIterations int
|
||||
MaxToolCalls int
|
||||
Workspace string
|
||||
Workspace string
|
||||
WorkspaceSharing *store.WorkspaceSharingConfig
|
||||
|
||||
// Per-agent DB overrides (nil = use global defaults)
|
||||
RestrictToWs *bool
|
||||
@@ -262,6 +264,7 @@ func NewLoop(cfg LoopConfig) *Loop {
|
||||
maxIterations: cfg.MaxIterations,
|
||||
maxToolCalls: cfg.MaxToolCalls,
|
||||
workspace: cfg.Workspace,
|
||||
workspaceSharing: cfg.WorkspaceSharing,
|
||||
restrictToWs: cfg.RestrictToWs,
|
||||
subagentsCfg: cfg.SubagentsCfg,
|
||||
memoryCfg: cfg.MemoryCfg,
|
||||
|
||||
@@ -39,6 +39,27 @@ func (l *Loop) scanWebToolResult(toolName string, result *tools.Result) {
|
||||
}
|
||||
}
|
||||
|
||||
// shouldShareWorkspace checks if the given user should share the base workspace
|
||||
// directory (skip per-user subfolder isolation) based on workspace_sharing config.
|
||||
func (l *Loop) shouldShareWorkspace(userID, peerKind string) bool {
|
||||
ws := l.workspaceSharing
|
||||
if ws == nil {
|
||||
return false
|
||||
}
|
||||
for _, u := range ws.SharedUsers {
|
||||
if u == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
switch peerKind {
|
||||
case "direct":
|
||||
return ws.SharedDM
|
||||
case "group":
|
||||
return ws.SharedGroup
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// InvalidateUserWorkspace clears the cached workspace for a user,
|
||||
// forcing the next request to re-read from user_agent_profiles.
|
||||
func (l *Loop) InvalidateUserWorkspace(userID string) {
|
||||
|
||||
@@ -386,6 +386,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
|
||||
BuiltinToolSettings: builtinSettings,
|
||||
ThinkingLevel: ag.ParseThinkingLevel(),
|
||||
SelfEvolve: ag.ParseSelfEvolve(),
|
||||
WorkspaceSharing: ag.ParseWorkspaceSharing(),
|
||||
GroupWriterCache: deps.GroupWriterCache,
|
||||
TeamStore: deps.TeamStore,
|
||||
MediaStore: deps.MediaStore,
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
func TestShouldShareWorkspace_NilConfig(t *testing.T) {
|
||||
l := &Loop{workspaceSharing: nil}
|
||||
if l.shouldShareWorkspace("user1", "direct") {
|
||||
t.Error("nil config should never share")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldShareWorkspace_SharedDM(t *testing.T) {
|
||||
l := &Loop{workspaceSharing: &store.WorkspaceSharingConfig{SharedDM: true}}
|
||||
|
||||
if !l.shouldShareWorkspace("user1", "direct") {
|
||||
t.Error("shared_dm=true should share for direct peer")
|
||||
}
|
||||
if l.shouldShareWorkspace("user1", "group") {
|
||||
t.Error("shared_dm=true should NOT share for group peer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldShareWorkspace_SharedGroup(t *testing.T) {
|
||||
l := &Loop{workspaceSharing: &store.WorkspaceSharingConfig{SharedGroup: true}}
|
||||
|
||||
if !l.shouldShareWorkspace("group:telegram:-100", "group") {
|
||||
t.Error("shared_group=true should share for group peer")
|
||||
}
|
||||
if l.shouldShareWorkspace("user1", "direct") {
|
||||
t.Error("shared_group=true should NOT share for direct peer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldShareWorkspace_SharedUsers(t *testing.T) {
|
||||
l := &Loop{workspaceSharing: &store.WorkspaceSharingConfig{
|
||||
SharedUsers: []string{"telegram:386246614", "group:telegram:-100"},
|
||||
}}
|
||||
|
||||
if !l.shouldShareWorkspace("telegram:386246614", "direct") {
|
||||
t.Error("user in shared_users should share regardless of peerKind")
|
||||
}
|
||||
if !l.shouldShareWorkspace("group:telegram:-100", "group") {
|
||||
t.Error("group in shared_users should share")
|
||||
}
|
||||
if l.shouldShareWorkspace("unknown-user", "direct") {
|
||||
t.Error("user NOT in shared_users should not share")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldShareWorkspace_SharedUsersTakesPriority(t *testing.T) {
|
||||
// shared_dm=false, shared_group=false, but user is in shared_users
|
||||
l := &Loop{workspaceSharing: &store.WorkspaceSharingConfig{
|
||||
SharedDM: false,
|
||||
SharedGroup: false,
|
||||
SharedUsers: []string{"special-user"},
|
||||
}}
|
||||
|
||||
if !l.shouldShareWorkspace("special-user", "direct") {
|
||||
t.Error("shared_users should override shared_dm=false")
|
||||
}
|
||||
if l.shouldShareWorkspace("other-user", "direct") {
|
||||
t.Error("non-listed user should not share when shared_dm=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldShareWorkspace_UnknownPeerKind(t *testing.T) {
|
||||
l := &Loop{workspaceSharing: &store.WorkspaceSharingConfig{SharedDM: true, SharedGroup: true}}
|
||||
|
||||
if l.shouldShareWorkspace("user1", "unknown") {
|
||||
t.Error("unknown peerKind should default to not sharing")
|
||||
}
|
||||
if l.shouldShareWorkspace("user1", "") {
|
||||
t.Error("empty peerKind should default to not sharing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldShareWorkspace_BothEnabled(t *testing.T) {
|
||||
l := &Loop{workspaceSharing: &store.WorkspaceSharingConfig{
|
||||
SharedDM: true,
|
||||
SharedGroup: true,
|
||||
SharedUsers: []string{"extra-user"},
|
||||
}}
|
||||
|
||||
if !l.shouldShareWorkspace("user1", "direct") {
|
||||
t.Error("should share DM")
|
||||
}
|
||||
if !l.shouldShareWorkspace("group:tg:-100", "group") {
|
||||
t.Error("should share group")
|
||||
}
|
||||
if !l.shouldShareWorkspace("extra-user", "direct") {
|
||||
t.Error("should share listed user")
|
||||
}
|
||||
}
|
||||
@@ -154,6 +154,33 @@ func (a *AgentData) ParseSelfEvolve() bool {
|
||||
return cfg.SelfEvolve
|
||||
}
|
||||
|
||||
// WorkspaceSharingConfig controls per-user workspace isolation.
|
||||
// When shared_dm/shared_group is true, users share the base workspace directory
|
||||
// instead of each getting an isolated subfolder.
|
||||
type WorkspaceSharingConfig struct {
|
||||
SharedDM bool `json:"shared_dm"`
|
||||
SharedGroup bool `json:"shared_group"`
|
||||
SharedUsers []string `json:"shared_users,omitempty"`
|
||||
}
|
||||
|
||||
// ParseWorkspaceSharing extracts workspace_sharing from other_config JSONB.
|
||||
// Returns nil if not configured or all fields are default (isolation enabled).
|
||||
func (a *AgentData) ParseWorkspaceSharing() *WorkspaceSharingConfig {
|
||||
if len(a.OtherConfig) == 0 {
|
||||
return nil
|
||||
}
|
||||
var cfg struct {
|
||||
WS *WorkspaceSharingConfig `json:"workspace_sharing"`
|
||||
}
|
||||
if json.Unmarshal(a.OtherConfig, &cfg) != nil || cfg.WS == nil {
|
||||
return nil
|
||||
}
|
||||
if !cfg.WS.SharedDM && !cfg.WS.SharedGroup && len(cfg.WS.SharedUsers) == 0 {
|
||||
return nil
|
||||
}
|
||||
return cfg.WS
|
||||
}
|
||||
|
||||
// AgentShareData represents an agent share grant.
|
||||
type AgentShareData struct {
|
||||
BaseModel
|
||||
|
||||
@@ -22,7 +22,7 @@ export function StickySaveBar({
|
||||
savedLabel = "Saved",
|
||||
}: StickySaveBarProps) {
|
||||
return (
|
||||
<div className="sticky bottom-0 z-10 -mx-4 mt-6 border-t bg-background/80 px-4 py-3 backdrop-blur-sm sm:-mx-6 sm:px-6">
|
||||
<div className="sticky bottom-0 z-10 -mx-3 mt-6 border-t bg-background/80 px-3 py-3 backdrop-blur-sm sm:-mx-4 sm:px-4">
|
||||
{error && (
|
||||
<div className="mb-2 flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
|
||||
@@ -276,6 +276,18 @@
|
||||
"selectToolsDeny": "Select tools to deny...",
|
||||
"selectToolsAlsoAllow": "Select additional tools..."
|
||||
},
|
||||
"workspaceSharing": {
|
||||
"title": "Workspace Sharing",
|
||||
"description": "Control workspace file isolation between users",
|
||||
"sharedDm": "Share in DMs",
|
||||
"sharedDmTip": "All DM users share the same workspace directory instead of isolated per-user folders",
|
||||
"sharedGroup": "Share in Groups",
|
||||
"sharedGroupTip": "All members in a group chat share the same workspace directory",
|
||||
"sharedUsers": "Shared Users",
|
||||
"sharedUsersTip": "Specific user IDs that always share workspace regardless of DM/Group settings. Format: channel:userId (e.g. telegram:386246614)",
|
||||
"userIdPlaceholder": "Enter user ID (e.g. telegram:386246614)",
|
||||
"warning": "When sharing is enabled, all shared users can read and write files in the same workspace directory. Memory and context files remain isolated per user."
|
||||
},
|
||||
"compaction": {
|
||||
"title": "Compaction",
|
||||
"description": "Context window compaction and memory flush settings",
|
||||
|
||||
@@ -276,6 +276,18 @@
|
||||
"selectToolsDeny": "Chọn công cụ để từ chối...",
|
||||
"selectToolsAlsoAllow": "Chọn công cụ bổ sung..."
|
||||
},
|
||||
"workspaceSharing": {
|
||||
"title": "Chia sẻ Workspace",
|
||||
"description": "Kiểm soát cách cô lập file workspace giữa các người dùng",
|
||||
"sharedDm": "Chia sẻ trong DM",
|
||||
"sharedDmTip": "Tất cả người dùng DM dùng chung thư mục workspace thay vì thư mục riêng",
|
||||
"sharedGroup": "Chia sẻ trong Group",
|
||||
"sharedGroupTip": "Tất cả thành viên trong group chat dùng chung thư mục workspace",
|
||||
"sharedUsers": "User được chia sẻ",
|
||||
"sharedUsersTip": "Danh sách user ID cụ thể luôn dùng chung workspace. Định dạng: channel:userId (vd: telegram:386246614)",
|
||||
"userIdPlaceholder": "Nhập user ID (vd: telegram:386246614)",
|
||||
"warning": "Khi bật chia sẻ, tất cả người dùng được chia sẻ có thể đọc và ghi file trong cùng thư mục workspace. Bộ nhớ và file ngữ cảnh vẫn được cô lập theo từng người dùng."
|
||||
},
|
||||
"compaction": {
|
||||
"title": "Nén ngữ cảnh",
|
||||
"description": "Cài đặt nén cửa sổ ngữ cảnh và ghi nhớ trước nén",
|
||||
|
||||
@@ -276,6 +276,18 @@
|
||||
"selectToolsDeny": "选择要拒绝的工具...",
|
||||
"selectToolsAlsoAllow": "选择附加工具..."
|
||||
},
|
||||
"workspaceSharing": {
|
||||
"title": "工作区共享",
|
||||
"description": "控制用户之间的工作区文件隔离",
|
||||
"sharedDm": "在私聊中共享",
|
||||
"sharedDmTip": "启用后,所有私聊用户共享同一工作区目录",
|
||||
"sharedGroup": "在群组中共享",
|
||||
"sharedGroupTip": "启用后,群聊中的所有成员共享同一工作区目录",
|
||||
"sharedUsers": "共享用户",
|
||||
"sharedUsersTip": "始终共享工作区的特定用户ID。格式:channel:userId(例如 telegram:386246614)",
|
||||
"userIdPlaceholder": "输入用户ID(例如 telegram:386246614)",
|
||||
"warning": "启用共享后,所有共享用户可以读写同一工作区目录中的文件。记忆和上下文文件仍按用户隔离。"
|
||||
},
|
||||
"compaction": {
|
||||
"title": "压缩",
|
||||
"description": "上下文窗口压缩和压缩前记忆设置",
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
SandboxConfig,
|
||||
MemoryConfig,
|
||||
QualityGateConfig,
|
||||
WorkspaceSharingConfig,
|
||||
} from "@/types/agent";
|
||||
import {
|
||||
SubagentsSection,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
OtherConfigSection,
|
||||
QualityGatesSection,
|
||||
ThinkingSection,
|
||||
WorkspaceSharingSection,
|
||||
} from "./config-sections";
|
||||
|
||||
interface AgentConfigTabProps {
|
||||
@@ -52,7 +54,10 @@ export function AgentConfigTab({ agent, onUpdate }: AgentConfigTabProps) {
|
||||
const otherObj = (agent.other_config ?? {}) as Record<string, unknown>;
|
||||
const initialGates = (Array.isArray(otherObj.quality_gates) ? otherObj.quality_gates : []) as QualityGateConfig[];
|
||||
const initialThinkingLevel = (typeof otherObj.thinking_level === "string" ? otherObj.thinking_level : "off");
|
||||
const { quality_gates: _qg, thinking_level: _tl, ...otherWithoutManaged } = otherObj;
|
||||
const initialWsSharing = (otherObj.workspace_sharing ?? {}) as WorkspaceSharingConfig;
|
||||
const { workspace_sharing: _ws, quality_gates: _qg, thinking_level: _tl, ...otherWithoutManaged } = otherObj;
|
||||
|
||||
const [wsSharing, setWsSharing] = useState<WorkspaceSharingConfig>(initialWsSharing);
|
||||
|
||||
const [qgEnabled, setQgEnabled] = useState(initialGates.length > 0);
|
||||
const [qualityGates, setQualityGates] = useState<QualityGateConfig[]>(initialGates);
|
||||
@@ -95,6 +100,9 @@ export function AgentConfigTab({ agent, onUpdate }: AgentConfigTabProps) {
|
||||
if (thinkingLevel && thinkingLevel !== "off") {
|
||||
otherBase.thinking_level = thinkingLevel;
|
||||
}
|
||||
if (wsSharing.shared_dm || wsSharing.shared_group || (wsSharing.shared_users?.length ?? 0) > 0) {
|
||||
otherBase.workspace_sharing = wsSharing;
|
||||
}
|
||||
updates.other_config = Object.keys(otherBase).length > 0 ? otherBase : {};
|
||||
await onUpdate(updates);
|
||||
setSaved(true);
|
||||
@@ -108,6 +116,12 @@ export function AgentConfigTab({ agent, onUpdate }: AgentConfigTabProps) {
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-4">
|
||||
{/* Workspace & Security */}
|
||||
<WorkspaceSharingSection
|
||||
value={wsSharing}
|
||||
onChange={setWsSharing}
|
||||
/>
|
||||
|
||||
{/* Core */}
|
||||
<ThinkingSection
|
||||
value={thinkingLevel}
|
||||
|
||||
@@ -8,3 +8,4 @@ export { MemorySection } from "./memory-section";
|
||||
export { OtherConfigSection } from "./other-config-section";
|
||||
export { QualityGatesSection } from "./quality-gates-section";
|
||||
export { ThinkingSection } from "./thinking-section";
|
||||
export { WorkspaceSharingSection } from "./workspace-sharing-section";
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { Shield, X, AlertTriangle, Plus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { WorkspaceSharingConfig } from "@/types/agent";
|
||||
import { useContacts } from "@/pages/contacts/hooks/use-contacts";
|
||||
import { InfoLabel } from "./config-section";
|
||||
|
||||
const MAX_SHARED_USERS = 100;
|
||||
|
||||
interface WorkspaceSharingSectionProps {
|
||||
value: WorkspaceSharingConfig;
|
||||
onChange: (v: WorkspaceSharingConfig) => void;
|
||||
}
|
||||
|
||||
export function WorkspaceSharingSection({ value, onChange }: WorkspaceSharingSectionProps) {
|
||||
const { t } = useTranslation("agents");
|
||||
const s = "configSections.workspaceSharing";
|
||||
const [contactSearch, setContactSearch] = useState("");
|
||||
|
||||
// Fetch contacts for the combobox
|
||||
const { contacts } = useContacts({ search: contactSearch, limit: 20 });
|
||||
|
||||
// Build combobox options from contacts, excluding already-added users
|
||||
const existingUsers = value.shared_users ?? [];
|
||||
const contactOptions = useMemo(() => {
|
||||
const existing = new Set(existingUsers);
|
||||
return contacts
|
||||
.filter((c) => c.user_id && !existing.has(c.user_id))
|
||||
.map((c) => ({
|
||||
value: c.user_id!,
|
||||
label: c.display_name
|
||||
? `${c.display_name} (${c.user_id})`
|
||||
: c.user_id!,
|
||||
}));
|
||||
}, [contacts, existingUsers]);
|
||||
|
||||
const addUser = (userId: string) => {
|
||||
const trimmed = userId.trim();
|
||||
if (!trimmed) return;
|
||||
if (existingUsers.includes(trimmed)) return;
|
||||
if (existingUsers.length >= MAX_SHARED_USERS) return;
|
||||
onChange({ ...value, shared_users: [...existingUsers, trimmed] });
|
||||
setContactSearch("");
|
||||
};
|
||||
|
||||
const removeUser = (idx: number) => {
|
||||
const updated = existingUsers.filter((_, i) => i !== idx);
|
||||
onChange({ ...value, shared_users: updated.length > 0 ? updated : undefined });
|
||||
};
|
||||
|
||||
const isActive = !!value.shared_dm || !!value.shared_group || existingUsers.length > 0;
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
{/* Header — prominent with shield icon */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-100 dark:bg-amber-900/30">
|
||||
<Shield className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">{t(`${s}.title`)}</h3>
|
||||
<p className="text-xs text-muted-foreground">{t(`${s}.description`)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content card with accent border when active */}
|
||||
<div className={`rounded-lg border p-3 space-y-4 sm:p-4 ${isActive ? "border-amber-400/60 bg-amber-50/30 dark:border-amber-500/30 dark:bg-amber-950/10" : ""}`}>
|
||||
{/* Security warning */}
|
||||
<Alert variant="destructive" className="border-amber-500/50 bg-amber-500/10 text-amber-700 dark:text-amber-400 [&>svg]:text-amber-600">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription className="text-xs">
|
||||
{t(`${s}.warning`)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* DM / Group sharing toggles */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="flex items-center justify-between rounded-md border bg-background p-3">
|
||||
<InfoLabel tip={t(`${s}.sharedDmTip`)}>{t(`${s}.sharedDm`)}</InfoLabel>
|
||||
<Switch
|
||||
checked={value.shared_dm ?? false}
|
||||
onCheckedChange={(v) => onChange({ ...value, shared_dm: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-md border bg-background p-3">
|
||||
<InfoLabel tip={t(`${s}.sharedGroupTip`)}>{t(`${s}.sharedGroup`)}</InfoLabel>
|
||||
<Switch
|
||||
checked={value.shared_group ?? false}
|
||||
onCheckedChange={(v) => onChange({ ...value, shared_group: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shared users allowlist with contact search */}
|
||||
<div className="space-y-2">
|
||||
<InfoLabel tip={t(`${s}.sharedUsersTip`)}>{t(`${s}.sharedUsers`)}</InfoLabel>
|
||||
{existingUsers.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{existingUsers.map((u, i) => (
|
||||
<Badge key={i} variant="secondary" className="gap-1 pr-1">
|
||||
<span className="max-w-48 truncate">{u}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeUser(i)}
|
||||
className="rounded-full p-0.5 hover:bg-muted-foreground/20"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Combobox
|
||||
value={contactSearch}
|
||||
onChange={(val) => setContactSearch(val)}
|
||||
options={contactOptions}
|
||||
placeholder={t(`${s}.userIdPlaceholder`)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => addUser(contactSearch)}
|
||||
disabled={!contactSearch.trim()}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -77,6 +77,12 @@ export interface QualityGateConfig {
|
||||
timeout_seconds?: number;
|
||||
}
|
||||
|
||||
export interface WorkspaceSharingConfig {
|
||||
shared_dm?: boolean;
|
||||
shared_group?: boolean;
|
||||
shared_users?: string[];
|
||||
}
|
||||
|
||||
export interface AgentData {
|
||||
id: string;
|
||||
agent_key: string;
|
||||
|
||||
Reference in new issue
Block a user