mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 18:15:47 +00:00
fix(ui): combobox viewport flip, multi-user picker, merge data migration
- fix(ui): combobox dropdown flips above input near viewport bottom using bottom-anchored positioning so filtered items shrink upward correctly - fix(ui): add onSelect callback to Combobox for commit-only events, preventing keystroke-adds-badge bug in multi-select mode - feat(ui): MultiUserPicker shared component for allow/deny user fields - fix(ui): migrate channel allow_from textarea, team allowed/denied users, and config allow_from input to MultiUserPicker with unified search - fix(ui): remove dead getKnownUsers from use-teams hook - feat: migrate per-user data on contact merge (context files, overrides, profiles, memory) with DO NOTHING conflict strategy — canonical tenant user data always wins over newly merged contact data
This commit is contained in:
1 parent
63ed4e9351
commit
23c227e18d
14 files changed
+410
-47
No files matched your search
@@ -50,6 +50,7 @@ func (h *ChannelInstancesHandler) handleMergeContacts(w http.ResponseWriter, r *
|
||||
}
|
||||
|
||||
var targetID uuid.UUID
|
||||
var targetUserID string // tenant_user.user_id string for context file migration
|
||||
|
||||
if hasTU {
|
||||
// Link to existing tenant_user — verify same tenant.
|
||||
@@ -63,6 +64,7 @@ func (h *ChannelInstancesHandler) handleMergeContacts(w http.ResponseWriter, r *
|
||||
return
|
||||
}
|
||||
targetID = tu.ID
|
||||
targetUserID = tu.UserID
|
||||
} else {
|
||||
// Create new tenant_user.
|
||||
userID := body.CreateUser.UserID
|
||||
@@ -82,6 +84,7 @@ func (h *ChannelInstancesHandler) handleMergeContacts(w http.ResponseWriter, r *
|
||||
return
|
||||
}
|
||||
targetID = tu.ID
|
||||
targetUserID = tu.UserID
|
||||
}
|
||||
|
||||
if err := h.contactStore.MergeContacts(r.Context(), body.ContactIDs, targetID); err != nil {
|
||||
@@ -90,6 +93,9 @@ func (h *ChannelInstancesHandler) handleMergeContacts(w http.ResponseWriter, r *
|
||||
return
|
||||
}
|
||||
|
||||
// Migrate user_context_files from old sender_ids to new tenant_user_id.
|
||||
h.migrateContextFilesOnMerge(r.Context(), body.ContactIDs, targetUserID)
|
||||
|
||||
emitAudit(h.msgBus, r, "contacts.merged", "tenant_user", targetID.String())
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"merged_id": targetID,
|
||||
@@ -182,6 +188,30 @@ func (h *ChannelInstancesHandler) handleListTenantUsers(w http.ResponseWriter, r
|
||||
writeJSON(w, http.StatusOK, map[string]any{"users": users})
|
||||
}
|
||||
|
||||
// migrateContextFilesOnMerge moves user_context_files from old sender_ids to the new tenant_user_id.
|
||||
// Best-effort: log errors but don't fail the merge.
|
||||
func (h *ChannelInstancesHandler) migrateContextFilesOnMerge(ctx context.Context, contactIDs []uuid.UUID, newUserID string) {
|
||||
// Batch-fetch sender_ids from merged contacts in one query.
|
||||
oldUserIDs, err := h.contactStore.GetSenderIDsByContactIDs(ctx, contactIDs)
|
||||
if err != nil {
|
||||
slog.Warn("contacts.merge.get_sender_ids", "error", err)
|
||||
return
|
||||
}
|
||||
// Filter out the target user_id itself (no self-migration needed).
|
||||
filtered := oldUserIDs[:0]
|
||||
for _, id := range oldUserIDs {
|
||||
if id != newUserID {
|
||||
filtered = append(filtered, id)
|
||||
}
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return
|
||||
}
|
||||
if err := h.agentStore.MigrateUserDataOnMerge(ctx, filtered, newUserID); err != nil {
|
||||
slog.Warn("contacts.merge.migrate_context_files", "error", err, "old_ids", filtered, "new_id", newUserID)
|
||||
}
|
||||
}
|
||||
|
||||
// deriveUserIDFromContacts returns the first contact's username or sender_id as fallback user_id.
|
||||
func (h *ChannelInstancesHandler) deriveUserIDFromContacts(ctx context.Context, contactIDs []uuid.UUID) string {
|
||||
if len(contactIDs) == 0 {
|
||||
|
||||
@@ -505,6 +505,10 @@ type AgentContextStore interface {
|
||||
GetUserContextFiles(ctx context.Context, agentID uuid.UUID, userID string) ([]UserContextFileData, error)
|
||||
SetUserContextFile(ctx context.Context, agentID uuid.UUID, userID, fileName, content string) error
|
||||
DeleteUserContextFile(ctx context.Context, agentID uuid.UUID, userID, fileName string) error
|
||||
// MigrateUserDataOnMerge moves per-user data from oldUserIDs to newUserID when contacts are merged.
|
||||
// Covers: user_context_files, user_agent_overrides, user_agent_profiles, memory_documents/chunks.
|
||||
// On conflict, keeps the newest by updated_at. Best-effort per table.
|
||||
MigrateUserDataOnMerge(ctx context.Context, oldUserIDs []string, newUserID string) error
|
||||
GetUserOverride(ctx context.Context, agentID uuid.UUID, userID string) (*UserAgentOverrideData, error)
|
||||
SetUserOverride(ctx context.Context, override *UserAgentOverrideData) error
|
||||
}
|
||||
|
||||
@@ -53,6 +53,9 @@ type ContactStore interface {
|
||||
// GetContactByID returns a single contact by primary key. Tenant-scoped via context.
|
||||
GetContactByID(ctx context.Context, id uuid.UUID) (*ChannelContact, error)
|
||||
|
||||
// GetSenderIDsByContactIDs returns sender_id strings for the given contact UUIDs in one query.
|
||||
GetSenderIDsByContactIDs(ctx context.Context, contactIDs []uuid.UUID) ([]string, error)
|
||||
|
||||
// MergeContacts sets merged_id = tenantUserID on all given contact IDs,
|
||||
// linking them to a tenant_users identity. Tenant-scoped via context.
|
||||
MergeContacts(ctx context.Context, contactIDs []uuid.UUID, tenantUserID uuid.UUID) error
|
||||
|
||||
@@ -5,7 +5,10 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -123,6 +126,89 @@ func (s *PGAgentStore) DeleteUserContextFile(ctx context.Context, agentID uuid.U
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PGAgentStore) MigrateUserDataOnMerge(ctx context.Context, oldUserIDs []string, newUserID string) error {
|
||||
if len(oldUserIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
tClause, tArgs, _, err := scopeClause(ctx, len(oldUserIDs)+2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
placeholders := make([]string, len(oldUserIDs))
|
||||
baseArgs := make([]any, 0, len(oldUserIDs)+1+len(tArgs))
|
||||
for i, id := range oldUserIDs {
|
||||
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
||||
baseArgs = append(baseArgs, id)
|
||||
}
|
||||
inClause := strings.Join(placeholders, ",")
|
||||
newP := fmt.Sprintf("$%d", len(oldUserIDs)+1)
|
||||
baseArgs = append(baseArgs, newUserID)
|
||||
baseArgs = append(baseArgs, tArgs...)
|
||||
|
||||
// Args for delete (no newUserID param needed).
|
||||
delArgs := make([]any, len(oldUserIDs))
|
||||
copy(delArgs, baseArgs[:len(oldUserIDs)])
|
||||
delArgs = append(delArgs, tArgs...)
|
||||
|
||||
// Helper: migrate + delete for one table. DO NOTHING on conflict —
|
||||
// existing tenant user data always wins (canonical identity).
|
||||
migrate := func(insertQ, deleteQ string) {
|
||||
if _, err := s.db.ExecContext(ctx, insertQ, baseArgs...); err != nil {
|
||||
slog.Warn("merge.migrate", "error", err)
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, deleteQ, delArgs...); err != nil {
|
||||
slog.Warn("merge.cleanup", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1. user_context_files: UNIQUE(agent_id, user_id, file_name)
|
||||
migrate(
|
||||
fmt.Sprintf(`INSERT INTO user_context_files (id, agent_id, user_id, file_name, content, updated_at, tenant_id)
|
||||
SELECT gen_random_uuid(), agent_id, %s, file_name, content, updated_at, tenant_id
|
||||
FROM user_context_files WHERE user_id IN (%s)%s
|
||||
ON CONFLICT (agent_id, user_id, file_name) DO NOTHING`, newP, inClause, tClause),
|
||||
fmt.Sprintf(`DELETE FROM user_context_files WHERE user_id IN (%s)%s`, inClause, tClause),
|
||||
)
|
||||
|
||||
// 2. user_agent_overrides: UNIQUE(agent_id, user_id)
|
||||
migrate(
|
||||
fmt.Sprintf(`INSERT INTO user_agent_overrides (id, agent_id, user_id, provider, model, settings, created_at, updated_at, tenant_id)
|
||||
SELECT gen_random_uuid(), agent_id, %s, provider, model, settings, created_at, updated_at, tenant_id
|
||||
FROM user_agent_overrides WHERE user_id IN (%s)%s
|
||||
ON CONFLICT (agent_id, user_id) DO NOTHING`, newP, inClause, tClause),
|
||||
fmt.Sprintf(`DELETE FROM user_agent_overrides WHERE user_id IN (%s)%s`, inClause, tClause),
|
||||
)
|
||||
|
||||
// 3. user_agent_profiles: PK(agent_id, user_id)
|
||||
migrate(
|
||||
fmt.Sprintf(`INSERT INTO user_agent_profiles (agent_id, user_id, workspace, first_seen_at, last_seen_at, metadata, tenant_id)
|
||||
SELECT agent_id, %s, workspace, first_seen_at, last_seen_at, metadata, tenant_id
|
||||
FROM user_agent_profiles WHERE user_id IN (%s)%s
|
||||
ON CONFLICT (agent_id, user_id) DO NOTHING`, newP, inClause, tClause),
|
||||
fmt.Sprintf(`DELETE FROM user_agent_profiles WHERE user_id IN (%s)%s`, inClause, tClause),
|
||||
)
|
||||
|
||||
// 4. memory_documents: UNIQUE(agent_id, COALESCE(user_id,''), path)
|
||||
migrate(
|
||||
fmt.Sprintf(`INSERT INTO memory_documents (id, agent_id, user_id, path, content, hash, updated_at, created_at, tenant_id)
|
||||
SELECT gen_random_uuid(), agent_id, %s, path, content, hash, updated_at, created_at, tenant_id
|
||||
FROM memory_documents WHERE user_id IN (%s)%s
|
||||
ON CONFLICT (agent_id, COALESCE(user_id,''), path) DO NOTHING`, newP, inClause, tClause),
|
||||
fmt.Sprintf(`DELETE FROM memory_documents WHERE user_id IN (%s)%s`, inClause, tClause),
|
||||
)
|
||||
|
||||
// 5. memory_chunks: FK on document_id — cascade from memory_documents delete handles this.
|
||||
// But orphan chunks (where document was already migrated) need cleanup.
|
||||
// Simply re-point remaining chunks whose document still has old user_id.
|
||||
repoint := fmt.Sprintf(`UPDATE memory_chunks SET user_id = %s WHERE user_id IN (%s)%s`, newP, inClause, tClause)
|
||||
if _, err := s.db.ExecContext(ctx, repoint, baseArgs...); err != nil {
|
||||
slog.Warn("merge.migrate_chunks", "error", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- User-Agent Profiles ---
|
||||
|
||||
func (s *PGAgentStore) GetOrCreateUserProfile(ctx context.Context, agentID uuid.UUID, userID, workspace, channel string) (bool, string, error) {
|
||||
|
||||
@@ -194,6 +194,36 @@ func (s *PGContactStore) GetContactByID(ctx context.Context, id uuid.UUID) (*sto
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (s *PGContactStore) GetSenderIDsByContactIDs(ctx context.Context, contactIDs []uuid.UUID) ([]string, error) {
|
||||
if len(contactIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
tid := store.TenantIDFromContext(ctx)
|
||||
placeholders := make([]string, len(contactIDs))
|
||||
args := make([]any, len(contactIDs)+1)
|
||||
for i, id := range contactIDs {
|
||||
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
||||
args[i] = id
|
||||
}
|
||||
args[len(contactIDs)] = tid
|
||||
q := fmt.Sprintf("SELECT sender_id FROM channel_contacts WHERE id IN (%s) AND tenant_id = $%d",
|
||||
strings.Join(placeholders, ","), len(contactIDs)+1)
|
||||
rows, err := s.db.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []string
|
||||
for rows.Next() {
|
||||
var sid string
|
||||
if err := rows.Scan(&sid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, sid)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PGContactStore) MergeContacts(ctx context.Context, contactIDs []uuid.UUID, tenantUserID uuid.UUID) error {
|
||||
if len(contactIDs) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -7,7 +7,10 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -133,6 +136,86 @@ func (s *SQLiteAgentStore) DeleteUserContextFile(ctx context.Context, agentID uu
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLiteAgentStore) MigrateUserDataOnMerge(ctx context.Context, oldUserIDs []string, newUserID string) error {
|
||||
if len(oldUserIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
tClause, tArgs, err := scopeClause(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
placeholders := make([]string, len(oldUserIDs))
|
||||
baseArgs := make([]any, 0, len(oldUserIDs)+1+len(tArgs))
|
||||
for i, id := range oldUserIDs {
|
||||
placeholders[i] = "?"
|
||||
baseArgs = append(baseArgs, id)
|
||||
}
|
||||
inClause := strings.Join(placeholders, ",")
|
||||
baseArgs = append(baseArgs, newUserID)
|
||||
baseArgs = append(baseArgs, tArgs...)
|
||||
|
||||
delArgs := make([]any, len(oldUserIDs))
|
||||
copy(delArgs, baseArgs[:len(oldUserIDs)])
|
||||
delArgs = append(delArgs, tArgs...)
|
||||
|
||||
uuid := `lower(hex(randomblob(4))||'-'||hex(randomblob(2))||'-'||hex(randomblob(2))||'-'||hex(randomblob(2))||'-'||hex(randomblob(6)))`
|
||||
|
||||
// DO NOTHING on conflict — existing tenant user data always wins.
|
||||
migrate := func(insertQ, deleteQ string) {
|
||||
if _, err := s.db.ExecContext(ctx, insertQ, baseArgs...); err != nil {
|
||||
slog.Warn("merge.migrate", "error", err)
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, deleteQ, delArgs...); err != nil {
|
||||
slog.Warn("merge.cleanup", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1. user_context_files
|
||||
migrate(
|
||||
fmt.Sprintf(`INSERT INTO user_context_files (id, agent_id, user_id, file_name, content, updated_at, tenant_id)
|
||||
SELECT %s, agent_id, ?, file_name, content, updated_at, tenant_id
|
||||
FROM user_context_files WHERE user_id IN (%s)%s
|
||||
ON CONFLICT (agent_id, user_id, file_name) DO NOTHING`, uuid, inClause, tClause),
|
||||
fmt.Sprintf(`DELETE FROM user_context_files WHERE user_id IN (%s)%s`, inClause, tClause),
|
||||
)
|
||||
|
||||
// 2. user_agent_overrides
|
||||
migrate(
|
||||
fmt.Sprintf(`INSERT INTO user_agent_overrides (id, agent_id, user_id, provider, model, settings, created_at, updated_at, tenant_id)
|
||||
SELECT %s, agent_id, ?, provider, model, settings, created_at, updated_at, tenant_id
|
||||
FROM user_agent_overrides WHERE user_id IN (%s)%s
|
||||
ON CONFLICT (agent_id, user_id) DO NOTHING`, uuid, inClause, tClause),
|
||||
fmt.Sprintf(`DELETE FROM user_agent_overrides WHERE user_id IN (%s)%s`, inClause, tClause),
|
||||
)
|
||||
|
||||
// 3. user_agent_profiles
|
||||
migrate(
|
||||
fmt.Sprintf(`INSERT INTO user_agent_profiles (agent_id, user_id, workspace, first_seen_at, last_seen_at, metadata, tenant_id)
|
||||
SELECT agent_id, ?, workspace, first_seen_at, last_seen_at, metadata, tenant_id
|
||||
FROM user_agent_profiles WHERE user_id IN (%s)%s
|
||||
ON CONFLICT (agent_id, user_id) DO NOTHING`, inClause, tClause),
|
||||
fmt.Sprintf(`DELETE FROM user_agent_profiles WHERE user_id IN (%s)%s`, inClause, tClause),
|
||||
)
|
||||
|
||||
// 4. memory_documents
|
||||
migrate(
|
||||
fmt.Sprintf(`INSERT INTO memory_documents (id, agent_id, user_id, path, content, hash, updated_at, created_at, tenant_id)
|
||||
SELECT %s, agent_id, ?, path, content, hash, updated_at, created_at, tenant_id
|
||||
FROM memory_documents WHERE user_id IN (%s)%s
|
||||
ON CONFLICT (agent_id, COALESCE(user_id,''), path) DO NOTHING`, uuid, inClause, tClause),
|
||||
fmt.Sprintf(`DELETE FROM memory_documents WHERE user_id IN (%s)%s`, inClause, tClause),
|
||||
)
|
||||
|
||||
// 5. memory_chunks: re-point remaining chunks.
|
||||
repoint := fmt.Sprintf(`UPDATE memory_chunks SET user_id = ? WHERE user_id IN (%s)%s`, inClause, tClause)
|
||||
if _, err := s.db.ExecContext(ctx, repoint, baseArgs...); err != nil {
|
||||
slog.Warn("merge.migrate_chunks", "error", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- User-Agent Profiles ---
|
||||
|
||||
// GetOrCreateUserProfile upserts a user profile and returns (isNew, effectiveWorkspace, error).
|
||||
|
||||
@@ -191,6 +191,36 @@ func (s *SQLiteContactStore) GetContactByID(ctx context.Context, id uuid.UUID) (
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteContactStore) GetSenderIDsByContactIDs(ctx context.Context, contactIDs []uuid.UUID) ([]string, error) {
|
||||
if len(contactIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
tid := store.TenantIDFromContext(ctx)
|
||||
placeholders := make([]string, len(contactIDs))
|
||||
args := make([]any, len(contactIDs)+1)
|
||||
for i, id := range contactIDs {
|
||||
placeholders[i] = "?"
|
||||
args[i] = id
|
||||
}
|
||||
args[len(contactIDs)] = tid
|
||||
q := fmt.Sprintf("SELECT sender_id FROM channel_contacts WHERE id IN (%s) AND tenant_id = ?",
|
||||
strings.Join(placeholders, ","))
|
||||
rows, err := s.db.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []string
|
||||
for rows.Next() {
|
||||
var sid string
|
||||
if err := rows.Scan(&sid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, sid)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLiteContactStore) MergeContacts(ctx context.Context, contactIDs []uuid.UUID, tenantUserID uuid.UUID) error {
|
||||
if len(contactIDs) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { UserPickerCombobox } from "./user-picker-combobox";
|
||||
|
||||
interface MultiUserPickerProps {
|
||||
value: string[];
|
||||
onChange: (values: string[]) => void;
|
||||
placeholder?: string;
|
||||
/** Filter by source: "contact" | "tenant_user" | undefined (both). */
|
||||
source?: "contact" | "tenant_user";
|
||||
/** Filter contacts by peer_kind. */
|
||||
peerKind?: "direct" | "group";
|
||||
/** Allow typing custom values not in the list. Default true. */
|
||||
allowCustom?: boolean;
|
||||
/** Render dropdown into a portal container (useful inside dialogs). */
|
||||
portalContainer?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-select user picker: search + select → badge list.
|
||||
* Wraps UserPickerCombobox for multi-value fields (allow_from, deny lists).
|
||||
*
|
||||
* Uses `onSelect` (fires on dropdown click / custom commit) instead of
|
||||
* `onChange` (fires on every keystroke) to avoid adding partial text.
|
||||
*/
|
||||
export function MultiUserPicker({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
source,
|
||||
peerKind,
|
||||
allowCustom = true,
|
||||
portalContainer,
|
||||
}: MultiUserPickerProps) {
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
|
||||
const handleCommit = (val: string) => {
|
||||
const trimmed = val.trim();
|
||||
if (trimmed && !value.includes(trimmed)) {
|
||||
onChange([...value, trimmed]);
|
||||
}
|
||||
setInputValue("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<UserPickerCombobox
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onSelect={handleCommit}
|
||||
placeholder={placeholder}
|
||||
source={source}
|
||||
peerKind={peerKind}
|
||||
allowCustom={allowCustom}
|
||||
portalContainer={portalContainer}
|
||||
/>
|
||||
{value.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{value.map((id) => (
|
||||
<Badge key={id} variant="secondary" className="gap-1 pr-1">
|
||||
{id}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(value.filter((v) => v !== id))}
|
||||
className="relative ml-0.5 cursor-pointer rounded-full p-0.5 hover:bg-muted after:absolute after:-inset-2 after:content-[''] md:after:hidden"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { useUserPicker } from "@/hooks/use-user-picker";
|
||||
interface UserPickerComboboxProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/** Fires only on dropdown item click or custom value commit (not keystrokes). */
|
||||
onSelect?: (value: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
/** Filter contacts by peer_kind: "direct" | "group" | undefined (all). */
|
||||
@@ -31,6 +33,7 @@ interface UserPickerComboboxProps {
|
||||
export function UserPickerCombobox({
|
||||
value,
|
||||
onChange,
|
||||
onSelect,
|
||||
placeholder,
|
||||
className,
|
||||
peerKind,
|
||||
@@ -44,6 +47,7 @@ export function UserPickerCombobox({
|
||||
<Combobox
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onSelect={onSelect}
|
||||
options={options}
|
||||
placeholder={placeholder}
|
||||
className={className}
|
||||
|
||||
@@ -11,6 +11,9 @@ export interface ComboboxOption {
|
||||
interface ComboboxProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/** Fires only on dropdown item click or custom value commit (not on keystrokes).
|
||||
* Use this for multi-select wrappers that need to distinguish typing from selection. */
|
||||
onSelect?: (value: string) => void;
|
||||
options: ComboboxOption[];
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
@@ -25,6 +28,7 @@ interface ComboboxProps {
|
||||
export function Combobox({
|
||||
value,
|
||||
onChange,
|
||||
onSelect,
|
||||
options,
|
||||
placeholder,
|
||||
className,
|
||||
@@ -75,28 +79,62 @@ export function Combobox({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [portalContainer, open]);
|
||||
|
||||
// Compute dropdown position — always use fixed positioning for portal rendering
|
||||
// Compute dropdown position — flip above input when near viewport bottom.
|
||||
// When flipped up, use `bottom` anchoring so the dropdown grows upward
|
||||
// from the input edge (filtering reduces items without creating a gap).
|
||||
React.useLayoutEffect(() => {
|
||||
if (!open || !containerRef.current) return;
|
||||
const inputRect = containerRef.current.getBoundingClientRect();
|
||||
const DROP_H = 256; // max-h-60 ≈ 240px + border/padding
|
||||
const GAP = 4;
|
||||
const spaceBelow = window.innerHeight - inputRect.bottom;
|
||||
const flipUp = spaceBelow < DROP_H && inputRect.top > DROP_H;
|
||||
|
||||
if (resolvedPortal) {
|
||||
const portalRect = resolvedPortal.getBoundingClientRect();
|
||||
const scrollTop = resolvedPortal.scrollTop || 0;
|
||||
const scrollLeft = resolvedPortal.scrollLeft || 0;
|
||||
const left = inputRect.left - portalRect.left + scrollLeft;
|
||||
const maxWidth = portalRect.width - (inputRect.left - portalRect.left);
|
||||
const portalRelativeTop = inputRect.top - portalRect.top + scrollTop;
|
||||
const portalFlipUp = spaceBelow < DROP_H && portalRelativeTop > DROP_H;
|
||||
if (portalFlipUp) {
|
||||
// Anchor bottom edge to input top — dropdown grows upward.
|
||||
const portalH = resolvedPortal.scrollHeight || portalRect.height;
|
||||
setDropdownStyle({
|
||||
position: "absolute",
|
||||
bottom: portalH - portalRelativeTop + GAP,
|
||||
left,
|
||||
width: inputRect.width,
|
||||
maxWidth,
|
||||
maxHeight: DROP_H,
|
||||
zIndex: 50,
|
||||
});
|
||||
} else {
|
||||
setDropdownStyle({
|
||||
position: "absolute",
|
||||
top: inputRect.bottom - portalRect.top + scrollTop + GAP,
|
||||
left,
|
||||
width: inputRect.width,
|
||||
maxWidth,
|
||||
zIndex: 50,
|
||||
});
|
||||
}
|
||||
} else if (flipUp) {
|
||||
// Fixed: anchor bottom edge to input top — dropdown grows upward.
|
||||
const bottomFromViewport = window.innerHeight - inputRect.top;
|
||||
setDropdownStyle({
|
||||
position: "absolute",
|
||||
top: inputRect.bottom - portalRect.top + scrollTop + 4,
|
||||
left,
|
||||
position: "fixed",
|
||||
bottom: bottomFromViewport + GAP,
|
||||
left: inputRect.left,
|
||||
width: inputRect.width,
|
||||
maxWidth,
|
||||
zIndex: 50,
|
||||
maxHeight: DROP_H,
|
||||
zIndex: 9999,
|
||||
});
|
||||
} else {
|
||||
setDropdownStyle({
|
||||
position: "fixed",
|
||||
top: inputRect.bottom + 4,
|
||||
top: inputRect.bottom + GAP,
|
||||
left: inputRect.left,
|
||||
width: inputRect.width,
|
||||
zIndex: 9999,
|
||||
@@ -125,6 +163,7 @@ export function Combobox({
|
||||
|
||||
const handleSelect = (val: string) => {
|
||||
onChange(val);
|
||||
onSelect?.(val);
|
||||
const match = options.find((o) => o.value === val);
|
||||
setSearch(match?.label || val);
|
||||
setOpen(false);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { MultiUserPicker } from "@/components/shared/multi-user-picker";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -274,16 +275,10 @@ function FieldRenderer({
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<Textarea
|
||||
id={id}
|
||||
value={Array.isArray(value) ? (value as string[]).join("\n") : ""}
|
||||
onChange={(e) => {
|
||||
const lines = e.target.value.split(/[\n,]/).map((l) => l.trim()).filter(Boolean);
|
||||
onChange(lines.length > 0 ? lines : undefined);
|
||||
}}
|
||||
<MultiUserPicker
|
||||
value={(value as string[]) ?? []}
|
||||
onChange={(v) => onChange(v.length > 0 ? v : undefined)}
|
||||
placeholder={field.placeholder ?? t("groupOverrides.fields.allowedUsersPlaceholder")}
|
||||
rows={3}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
{help && <p className="text-xs text-muted-foreground">{help}</p>}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { MultiUserPicker } from "@/components/shared/multi-user-picker";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
type ChannelsData = Record<string, any>;
|
||||
@@ -177,11 +178,11 @@ export function ChannelsSection({ data, onSave, saving }: Props) {
|
||||
{chData.allow_from !== undefined && (
|
||||
<div className="grid gap-1.5">
|
||||
<Label>{t("channels.allowFrom")}</Label>
|
||||
<Input
|
||||
value={(chData.allow_from ?? []).join(", ")}
|
||||
onChange={(e) =>
|
||||
<MultiUserPicker
|
||||
value={chData.allow_from ?? []}
|
||||
onChange={(v) =>
|
||||
updateChannel(ch, {
|
||||
allow_from: e.target.value.split(",").map((s: string) => s.trim()).filter(Boolean),
|
||||
allow_from: v.length > 0 ? v : undefined,
|
||||
})
|
||||
}
|
||||
placeholder={t("channels.allowFromPlaceholder")}
|
||||
|
||||
@@ -269,21 +269,10 @@ export function useTeams() {
|
||||
[ws],
|
||||
);
|
||||
|
||||
const getKnownUsers = useCallback(
|
||||
async (teamId: string): Promise<string[]> => {
|
||||
const res = await ws.call<{ users: string[] }>(
|
||||
Methods.TEAMS_KNOWN_USERS,
|
||||
{ teamId },
|
||||
);
|
||||
return res.users ?? [];
|
||||
},
|
||||
[ws],
|
||||
);
|
||||
|
||||
return {
|
||||
teams, loading, load, createTeam, deleteTeam, getTeam, getTeamTasks, getTeamScopes,
|
||||
getTaskDetail, getTaskLight, approveTask, rejectTask, addTaskComment, getTaskComments, getTaskEvents,
|
||||
createTask, deleteTask, deleteTasksBulk, assignTask,
|
||||
addMember, removeMember, updateTeamSettings, getKnownUsers,
|
||||
addMember, removeMember, updateTeamSettings,
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { Combobox } from "@/components/ui/combobox";
|
||||
import { X, Save, Bell, ShieldAlert, Clock, FolderLock, FolderSync, Zap, Bot, Loader2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CHANNEL_TYPES } from "@/constants/channels";
|
||||
import { MultiUserPicker } from "@/components/shared/multi-user-picker";
|
||||
import type { TeamData, TeamAccessSettings, TeamNotifyConfig, EscalationMode, EscalationAction } from "@/types/team";
|
||||
import { useTeams } from "./hooks/use-teams";
|
||||
|
||||
@@ -15,6 +16,7 @@ interface TeamSettingsTabProps {
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
/** Generic multi-select with static options (used for channel type selection). */
|
||||
function MultiSelect({
|
||||
options,
|
||||
selected,
|
||||
@@ -46,7 +48,7 @@ function MultiSelect({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(selected.filter((s) => s !== id))}
|
||||
className="ml-0.5 rounded-full p-0.5 hover:bg-muted"
|
||||
className="ml-0.5 cursor-pointer rounded-full p-0.5 hover:bg-muted"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
@@ -60,8 +62,7 @@ function MultiSelect({
|
||||
|
||||
export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps) {
|
||||
const { t } = useTranslation("teams");
|
||||
const { updateTeamSettings, getKnownUsers } = useTeams();
|
||||
const [knownUsers, setKnownUsers] = useState<string[]>([]);
|
||||
const { updateTeamSettings } = useTeams();
|
||||
|
||||
// Parse initial settings
|
||||
const initial = (team.settings ?? {}) as TeamAccessSettings;
|
||||
@@ -91,11 +92,6 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Load known users for combobox
|
||||
useEffect(() => {
|
||||
getKnownUsers(teamId).then(setKnownUsers).catch(() => {});
|
||||
}, [teamId, getKnownUsers]);
|
||||
|
||||
// Reset when team changes
|
||||
useEffect(() => {
|
||||
const s = (team.settings ?? {}) as TeamAccessSettings;
|
||||
@@ -165,7 +161,6 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
|
||||
}
|
||||
}, [teamId, allowUserIds, denyUserIds, allowChannels, denyChannels, notifyDispatched, notifyProgress, notifyFailed, notifyCompleted, notifyCommented, notifyNewTask, notifySlowTool, notifyMode, memberRequestsEnabled, memberRequestsAutoDispatch, escalationMode, escalationActions, blockerEscalationEnabled, followupInterval, followupMaxReminders, workspaceScope, updateTeamSettings, onSaved]);
|
||||
|
||||
const userOptions = knownUsers.map((u) => ({ value: u, label: u }));
|
||||
const channelOptions = CHANNEL_TYPES.map((c) => ({ value: c.value, label: c.label }));
|
||||
|
||||
return (
|
||||
@@ -407,9 +402,8 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.allowedUsersHint")}
|
||||
</p>
|
||||
<MultiSelect
|
||||
options={userOptions}
|
||||
selected={allowUserIds}
|
||||
<MultiUserPicker
|
||||
value={allowUserIds}
|
||||
onChange={setAllowUserIds}
|
||||
placeholder={t("settings.searchUsers")}
|
||||
/>
|
||||
@@ -419,9 +413,8 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.deniedUsersHint")}
|
||||
</p>
|
||||
<MultiSelect
|
||||
options={userOptions}
|
||||
selected={denyUserIds}
|
||||
<MultiUserPicker
|
||||
value={denyUserIds}
|
||||
onChange={setDenyUserIds}
|
||||
placeholder={t("settings.searchUsers")}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user