fix: UI bug fixes — settings, cron, MCP, WS, events (#855)

* fix: UI bug fixes — team settings, cron, MCP, WS reconnect, events, config

Critical:
- Preserve team settings version field to prevent v2→v1 downgrade
- Add blocker_escalation + slow_tool to BE whitelist struct
- Cron update only sends enabled when actually changed
- MCP form no longer strips mcp_ prefix from tool_prefix on edit
- Prevent duplicate pairing modals on WS reconnect

Medium:
- Fix events category filter using wrong filtered source
- Fix config behavior section leaking masked "***" token
- Fix agent WS fallback mapping wrong status/type values
- Fix offset=0 silently dropped in activity/vault/episodic hooks
- Fix channel/provider/cron dialog useEffect missing deps
- Fix mobile sidebar not closing on programmatic navigation
- Fix WS role not cleared on disconnect, auth failure infinite loop
- Fix SearchInput spurious calls from unstable onChange ref
- Fix pending-messages poll/timeout not cleaned up on unmount
- Fix SummaryBlock "Show more" button never appearing
- Fix session delete double-navigate, channel delete not awaited
- Fix skill view passing slug instead of name
- Fix storage listFiles silently swallowing errors
- Fix graph search not early-terminating on large graphs

Low:
- Remove unused sessionKey from handleAgentChange deps
- Use ROUTES.CHAT constant in sidebar
- Fix reconnect backoff off-by-one
- Fix cron advanced dialog stale form on prop update
- Clear vault search results on dialog close

* fix: address review findings — pairing guard reset, stale closure, search cleanup

- Reset pairingInProgress on disconnect() to prevent stuck pairing state
- Add initial to handleSave deps to prevent stale version closure
- Use handleOpenChange in vault search result select to clear stale results

---------

Co-authored-by: Luvu182 <208665161+Luvu182@users.noreply.github.com>
Co-authored-by: viettranx <viettranx@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-04-13 08:53:26 +07:00
1 parent 39038fddc4
commit c06d9d9705
28 files changed
+90 -44

No files matched your search

+5 -1
View File
@@ -312,19 +312,23 @@ func (m *TeamsMethods) handleUpdate(ctx context.Context, client *gateway.Client,
EscalationActions []string `json:"escalation_actions,omitempty"`
WorkspaceScope string `json:"workspace_scope,omitempty"`
WorkspaceQuotaMB *int `json:"workspace_quota_mb,omitempty"`
Notifications *struct {
Notifications *struct {
Dispatched *bool `json:"dispatched,omitempty"`
Progress *bool `json:"progress,omitempty"`
Failed *bool `json:"failed,omitempty"`
Completed *bool `json:"completed,omitempty"`
Commented *bool `json:"commented,omitempty"`
NewTask *bool `json:"new_task,omitempty"`
SlowTool *bool `json:"slow_tool,omitempty"`
Mode string `json:"mode,omitempty"`
} `json:"notifications,omitempty"`
MemberRequests *struct {
Enabled *bool `json:"enabled,omitempty"`
AutoDispatch *bool `json:"auto_dispatch,omitempty"`
} `json:"member_requests,omitempty"`
BlockerEscalation *struct {
Enabled *bool `json:"enabled,omitempty"`
} `json:"blocker_escalation,omitempty"`
}
raw, _ := json.Marshal(params.Settings)
var access teamAccessSettings
+9 -2
View File
@@ -21,6 +21,7 @@ export class WsClient {
private reconnectAttempts = 0;
private authenticated = false;
private intentionalClose = false;
private pairingInProgress = false;
private connectGeneration = 0;
/** Server-assigned role from connect response. */
@@ -90,6 +91,7 @@ export class WsClient {
disconnect(): void {
this.intentionalClose = true;
this.pairingInProgress = false;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
@@ -221,10 +223,14 @@ export class WsClient {
// Browser pairing: server requires approval
if (res?.status === "pending_pairing" && res.pairing_code && res.sender_id) {
this.onPairingRequired?.(res.pairing_code, res.sender_id);
if (!this.pairingInProgress) {
this.pairingInProgress = true;
this.onPairingRequired?.(res.pairing_code, res.sender_id);
}
// Keep connection alive for polling browser.pairing.status
return;
}
this.pairingInProgress = false;
// Server accepted connection but assigned viewer role → token is invalid
if (this.getToken() && res?.role === "viewer") {
@@ -251,6 +257,7 @@ export class WsClient {
this.onAuthFailure?.();
return;
}
this.intentionalClose = true;
this.ws?.close();
}
}
@@ -329,11 +336,11 @@ export class WsClient {
private scheduleReconnect(): void {
if (this.reconnectTimer) return;
this.reconnectAttempts++;
const delay = Math.min(
this.baseReconnectDelay * Math.pow(2, this.reconnectAttempts),
this.maxReconnectDelay,
);
this.reconnectAttempts++;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
@@ -166,7 +166,7 @@ export function SigmaGraphContainer({
onSigmaReady?.(null);
}
};
}, [graph, edgeType, compact]);
}, [graph, edgeType, compact, setSigmaRef]);
// --- Update theme colors without re-init ---
useEffect(() => {
@@ -36,13 +36,15 @@ export function SigmaGraphSearch({ sigma, graph, onNodeSelect, placeholder = "Se
}
const lower = q.toLowerCase();
const matches: SearchResult[] = [];
graph.forEachNode((node, attrs) => {
if (matches.length >= 10) return;
const nodes = graph.nodes();
for (let i = 0; i < nodes.length && matches.length < 10; i++) {
const node = nodes[i]!;
const attrs = graph.getNodeAttributes(node);
const label = (attrs.label as string) || "";
if (label.toLowerCase().includes(lower)) {
matches.push({ id: node, label, color: attrs.color as string });
}
});
}
setResults(matches);
setOpen(matches.length > 0);
setActiveIndex(0);
@@ -1,3 +1,4 @@
import { useEffect } from "react";
import { Outlet, useLocation } from "react-router";
import { WifiOff } from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -29,6 +30,13 @@ export function AppLayout() {
const connected = useAuthStore((s) => s.connected);
const isMobile = useIsTablet();
// Close mobile sidebar on route change (e.g. programmatic navigation, back button)
useEffect(() => {
if (isMobile && mobileSidebarOpen) {
setMobileSidebarOpen(false);
}
}, [location.pathname]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div className="flex h-dvh overflow-hidden safe-top">
{isMobile ? (
+1 -1
View File
@@ -84,7 +84,7 @@ export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) {
<nav className="flex-1 space-y-4 overflow-y-auto px-2 py-4">
<SidebarGroup label={t("groups.core")} collapsed={collapsed}>
<SidebarItem to={ROUTES.OVERVIEW} icon={LayoutDashboard} label={t("nav.overview")} collapsed={collapsed} />
<SidebarItem to="/chat" icon={MessageSquare} label={t("nav.chat")} collapsed={collapsed} />
<SidebarItem to={ROUTES.CHAT} icon={MessageSquare} label={t("nav.chat")} collapsed={collapsed} />
<SidebarItem to={ROUTES.AGENTS} icon={Bot} label={t("nav.agents")} collapsed={collapsed} />
<SidebarItem to={ROUTES.TEAMS} icon={Users} label={t("nav.agentTeams")} collapsed={collapsed} />
</SidebarGroup>
@@ -76,6 +76,7 @@ export function WsProvider({ children }: { children: React.ReactNode }) {
});
}
if (state === "disconnected") {
store.setRole("");
store.setTenant("", "", "", false);
store.setAvailableTenants([]);
store.setTenantSelected(false);
@@ -2,7 +2,7 @@ import { Search } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input";
import { useDebounce } from "@/hooks/use-debounce";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
interface SearchInputProps {
value: string;
@@ -22,10 +22,12 @@ export function SearchInput({
const { t } = useTranslation("common");
const [local, setLocal] = useState(value);
const debounced = useDebounce(local, delay);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
useEffect(() => {
onChange(debounced);
}, [debounced, onChange]);
onChangeRef.current(debounced);
}, [debounced]);
useEffect(() => {
setLocal(value);
@@ -42,8 +42,8 @@ export function useActivity() {
if (filters?.action) params.action = filters.action;
if (filters?.entity_type) params.entity_type = filters.entity_type;
if (filters?.actor_id) params.actor_id = filters.actor_id;
if (filters?.limit) params.limit = String(filters.limit);
if (filters?.offset) params.offset = String(filters.offset);
if (filters?.limit !== undefined) params.limit = String(filters.limit);
if (filters?.offset !== undefined) params.offset = String(filters.offset);
const res = await http.get<ActivityListResponse>("/v1/activity", params);
setLogs(res.logs ?? []);
+2 -2
View File
@@ -47,9 +47,9 @@ export function useAgents() {
max_tool_iterations: 0,
workspace: "",
restrict_to_workspace: false,
agent_type: "open" as const,
agent_type: (a as unknown as { agentType?: string }).agentType === "predefined" ? "predefined" as const : "open" as const,
is_default: false,
status: a.isRunning ? "running" : "idle",
status: a.isRunning ? "active" : "inactive",
}));
},
staleTime: 60_000,
@@ -65,7 +65,7 @@ export function ChannelAdvancedDialog({
if (!open) return;
setValues(deriveInitialValues(instance));
}, [open]);
}, [open, instance]);
const handleChange = useCallback((key: string, value: unknown) => {
setValues((prev) => ({ ...prev, [key]: value }));
@@ -67,7 +67,7 @@ export function ChannelDetailDialogs({
confirmValue={instance.display_name || instance.name}
confirmLabel={t("delete.confirmLabel")}
onConfirm={async () => {
onDelete({
await onDelete({
id: instance.id,
name: instance.display_name || instance.name,
});
+1 -1
View File
@@ -125,7 +125,7 @@ export function ChatPage() {
navigate("/chat");
}
},
[navigate, sessionKey],
[navigate],
);
const handleSend = useCallback(
@@ -83,9 +83,13 @@ export function BehaviorSection({ config, onPatch, saving }: Props) {
(v: T) => { setter(v); setDirty(true); };
const handleSave = () => {
// Strip masked/secret values to avoid overwriting real secrets with "***"
const gwClean = Object.fromEntries(
Object.entries(gw).filter(([, v]) => typeof v !== "string" || v !== "***"),
);
onPatch({
gateway: {
...gw,
...gwClean,
tool_status: ux.tool_status,
block_reply: ux.block_reply,
max_message_chars: rate.max_message_chars,
@@ -87,7 +87,7 @@ export function CronAdvancedDialog({ open, onOpenChange, job, onUpdate }: CronAd
reset(deriveDefaults(job));
fetchTargets();
}, [open]);
}, [open, job, reset, fetchTargets]);
const handleSave = async () => {
if (!onUpdate) {
@@ -96,18 +96,20 @@ export function CronOverviewTab({ job, onUpdate }: CronOverviewTabProps) {
} else {
schedule = { kind: "at" as const, atMs: job.schedule.atMs ?? Date.now() + 60000, tz: timezone !== "UTC" ? timezone : "" };
}
await onUpdate(job.id, {
const patch: import("../hooks/use-cron").CronJobPatch = {
schedule,
message: message.trim(),
agentId: agentId.trim() || "",
enabled,
deliver,
deliverChannel: deliver ? channel.trim() || undefined : undefined,
deliverTo: deliver ? to.trim() || undefined : undefined,
wakeHeartbeat,
deleteAfterRun,
stateless,
});
};
// Only include enabled in patch if it actually changed to avoid unintended toggles
if (enabled !== job.enabled) patch.enabled = enabled;
await onUpdate(job.id, patch);
setEditingMessage(false);
} catch {
// toast shown by hook
+2 -2
View File
@@ -58,7 +58,7 @@ export function EventsPage() {
if (!teamId) return t("global");
return teamMap.get(teamId) ?? teamId.slice(0, 8);
},
[teamMap],
[teamMap, t],
);
// Unique teams from events for filter dropdown
@@ -118,7 +118,7 @@ export function EventsPage() {
e.event.startsWith("team.member."),
);
}
return userFilteredEvents.filter((e) => e.event.startsWith(categoryFilter));
return chatFilteredEvents.filter((e) => e.event.startsWith(categoryFilter));
}, [chatFilteredEvents, categoryFilter]);
// Auto-scroll to bottom when new events arrive
+2 -2
View File
@@ -89,7 +89,7 @@ export function MCPFormDialog({ open, onOpenChange, server, onSubmit, onTest }:
url: server?.url ?? "",
headers: server?.headers ?? {},
env: server?.env ?? {},
toolPrefix: (server?.tool_prefix ?? "").replace(/^mcp_/, ""),
toolPrefix: server?.tool_prefix ?? "",
timeout: server?.timeout_sec ?? 60,
enabled: server?.enabled ?? true,
requireUserCreds: server?.settings?.require_user_credentials ?? false,
@@ -160,7 +160,7 @@ export function MCPFormDialog({ open, onOpenChange, server, onSubmit, onTest }:
});
onOpenChange(false);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : t("form.saving"));
setError(err instanceof Error ? err.message : t("form.errors.saveFailed", "Save failed"));
} finally {
setLoading(false);
}
@@ -13,8 +13,8 @@ export function useEpisodicSummaries(agentId: string, opts: { userId?: string; l
const params = useMemo(() => {
const p: Record<string, string> = {};
if (opts.userId) p.user_id = opts.userId;
if (opts.limit) p.limit = String(opts.limit);
if (opts.offset) p.offset = String(opts.offset);
if (opts.limit !== undefined) p.limit = String(opts.limit);
if (opts.offset !== undefined) p.offset = String(opts.offset);
return p;
}, [opts.userId, opts.limit, opts.offset]);
@@ -38,6 +38,10 @@ export function PendingMessagesPage() {
useEffect(() => {
loadGroups();
return () => {
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; }
};
}, [loadGroups]);
// Clear spinner as soon as the compacting group's has_summary becomes true
@@ -76,7 +76,7 @@ export function ProviderAdvancedDialog({
setAcpPermMode(s.acpPermMode);
setAcpWorkDir(s.acpWorkDir);
}, [open]);
}, [open, provider]);
const [saving, setSaving] = useState(false);
@@ -253,7 +253,6 @@ export function SessionDetailPage({
onConfirm={async () => {
await onDelete(session.key);
setConfirmDelete(false);
onBack();
}}
/>
@@ -52,7 +52,12 @@ export function SummaryBlock({ text }: { text: string }) {
useLayoutEffect(() => {
if (contentRef.current) {
setNeedsTruncation(contentRef.current.scrollHeight > SUMMARY_MAX_HEIGHT);
// Temporarily remove max-height to measure true content height
const el = contentRef.current;
const prev = el.style.maxHeight;
el.style.maxHeight = "none";
setNeedsTruncation(el.scrollHeight > SUMMARY_MAX_HEIGHT);
el.style.maxHeight = prev;
}
}, [text]);
@@ -62,7 +67,7 @@ export function SummaryBlock({ text }: { text: string }) {
<div
ref={contentRef}
className="mt-1 overflow-hidden transition-[max-height] duration-200"
style={{ maxHeight: expanded ? contentRef.current?.scrollHeight : SUMMARY_MAX_HEIGHT }}
style={{ maxHeight: expanded ? (contentRef.current?.scrollHeight ?? "none") : SUMMARY_MAX_HEIGHT }}
>
{text}
</div>
+1 -1
View File
@@ -46,7 +46,7 @@ export function SkillTableRow({
<button
type="button"
className="font-medium text-left hover:underline cursor-pointer"
onClick={() => onView(skill.slug ?? skill.name)}
onClick={() => onView(skill.name)}
>
{skill.name}
</button>
@@ -36,6 +36,8 @@ export function useStorage() {
const res = await http.get<StorageListResponse>("/v1/storage/files");
setFiles(res.files ?? []);
setBaseDir(res.baseDir ?? "");
} catch (err) {
toast.error(i18next.t("storage:toast.loadFailed", "Failed to load files"), userFriendlyError(err));
} finally {
if (!opts?.silent) setLoading(false);
}
+6 -6
View File
@@ -94,6 +94,8 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
try {
const data = form.getValues();
const settings: TeamAccessSettings = {};
// Preserve version from existing settings to prevent v2→v1 downgrade
if (initial.version !== undefined) settings.version = initial.version;
if (data.allowUserIds.length > 0) settings.allow_user_ids = data.allowUserIds;
if (data.denyUserIds.length > 0) settings.deny_user_ids = data.denyUserIds;
if (data.allowChannels.length > 0) settings.allow_channels = data.allowChannels;
@@ -109,16 +111,14 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
new_task: data.notifyNewTask,
};
settings.notifications = notifications;
if (data.memberRequestsEnabled) {
settings.member_requests = { enabled: true, auto_dispatch: data.memberRequestsAutoDispatch };
}
settings.member_requests = { enabled: data.memberRequestsEnabled, auto_dispatch: data.memberRequestsAutoDispatch };
if (escalationMode) {
settings.escalation_mode = escalationMode;
if (escalationActions.length > 0) settings.escalation_actions = escalationActions;
}
settings.blocker_escalation = { enabled: data.blockerEscalationEnabled };
if (data.followupInterval !== 30) settings.followup_interval_minutes = data.followupInterval;
if (data.followupMaxReminders !== 0) settings.followup_max_reminders = data.followupMaxReminders;
settings.followup_interval_minutes = data.followupInterval;
settings.followup_max_reminders = data.followupMaxReminders;
settings.workspace_scope = data.workspaceScope || "isolated";
await updateTeamSettings(teamId, settings);
onSaved();
@@ -126,7 +126,7 @@ export function TeamSettingsTab({ teamId, team, onSaved }: TeamSettingsTabProps)
} finally {
setSaving(false);
}
}, [teamId, form, escalationMode, escalationActions, updateTeamSettings, onSaved]);
}, [teamId, form, initial, escalationMode, escalationActions, updateTeamSettings, onSaved]);
const channelOptions = CHANNEL_TYPES.map((c) => ({ value: c.value, label: c.label }));
+2 -2
View File
@@ -28,8 +28,8 @@ export function useVaultDocuments(agentId: string, opts: VaultListOpts) {
if (opts.scope) p.scope = opts.scope;
if (opts.docType) p.doc_type = opts.docType;
if (opts.teamId) p.team_id = opts.teamId;
if (opts.limit) p.limit = String(opts.limit);
if (opts.offset) p.offset = String(opts.offset);
if (opts.limit !== undefined) p.limit = String(opts.limit);
if (opts.offset !== undefined) p.offset = String(opts.offset);
return p;
}, [agentId, opts.scope, opts.docType, opts.teamId, opts.limit, opts.offset]);
@@ -25,6 +25,12 @@ export function VaultSearchDialog({ agentId, open, onOpenChange, onSelectResult
const [results, setResults] = useState<VaultSearchResult[]>([]);
const [searching, setSearching] = useState(false);
// Clear stale results when dialog closes
const handleOpenChange = useCallback((v: boolean) => {
if (!v) { setQuery(""); setResults([]); }
onOpenChange(v);
}, [onOpenChange]);
const handleSearch = useCallback(async () => {
if (!query.trim()) return;
setSearching(true);
@@ -39,7 +45,7 @@ export function VaultSearchDialog({ agentId, open, onOpenChange, onSelectResult
}, [query, search]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-lg max-sm:inset-0">
<DialogHeader>
<DialogTitle>{t("search")}</DialogTitle>
@@ -73,7 +79,7 @@ export function VaultSearchDialog({ agentId, open, onOpenChange, onSelectResult
className="w-full text-left p-2 rounded hover:bg-muted/50 flex items-center justify-between gap-2"
onClick={() => {
onSelectResult(r.document);
onOpenChange(false);
handleOpenChange(false);
}}
>
<div className="min-w-0">