Skip to content

Commit d2b0936

Browse files
feat: enhance scheduling capabilities with cron support
- Added support for cron expressions in the ScheduleEditor component, allowing users to define schedules using cron syntax. - Updated parsing and building functions to handle the new cron schedule type. - Enhanced the user interface to include a dedicated input for cron expressions and display human-readable descriptions of cron schedules. - Improved the ChatWindow component to manage model selection and display based on screen size, including adaptive behavior for mobile and desktop views. - Implemented logic to disconnect existing clients when reconnecting to prevent multiple active connections.
1 parent 15d80bb commit d2b0936

3 files changed

Lines changed: 265 additions & 119 deletions

File tree

packages/plugin/src/channel.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,14 @@ export const botschatPlugin = {
240240
return;
241241
}
242242

243+
const existingClient = cloudClients.get(accountId);
244+
if (existingClient) {
245+
log?.info(`[${accountId}] Disconnecting previous client before reconnect`);
246+
existingClient.disconnect();
247+
cloudClients.delete(accountId);
248+
cloudUrls.delete(accountId);
249+
}
250+
243251
ctx.setStatus({
244252
...ctx.getStatus(),
245253
accountId,
@@ -933,6 +941,8 @@ async function openclawCronAdd(
933941
const s = (msg.schedule || "").trim();
934942
if (/^at\s+/i.test(s)) {
935943
args.push("--at", s.replace(/^at\s+/i, ""));
944+
} else if (/^cron\s+/i.test(s)) {
945+
args.push("--cron", s.replace(/^cron\s+/i, ""));
936946
} else if (s) {
937947
args.push("--every", s.replace(/^every\s+/i, ""));
938948
}
@@ -1007,6 +1017,8 @@ async function handleTaskSchedule(
10071017
const s = msg.schedule.trim();
10081018
if (/^at\s+/i.test(s)) {
10091019
args.push("--at", s.replace(/^at\s+/i, ""));
1020+
} else if (/^cron\s+/i.test(s)) {
1021+
args.push("--cron", s.replace(/^cron\s+/i, ""));
10101022
} else {
10111023
args.push("--every", s.replace(/^every\s+/i, ""));
10121024
}
@@ -1638,6 +1650,8 @@ async function handleTaskScanRequest(
16381650
else scheduleStr = `every ${ms / 1000}s`;
16391651
} else if (job.schedule.kind === "at" && job.schedule.at) {
16401652
scheduleStr = `at ${job.schedule.at}`;
1653+
} else if (job.schedule.kind === "cron" && (job.schedule as any).expr) {
1654+
scheduleStr = `cron ${(job.schedule as any).expr}`;
16411655
}
16421656
}
16431657

packages/web/src/components/ChatWindow.tsx

Lines changed: 131 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import React, { useRef, useEffect, useState, useMemo, useCallback } from "react"
22
import { useAppState, useAppDispatch, type ChatMessage } from "../store";
33
import type { WSMessage } from "../ws";
44
import { MessageContent } from "./MessageContent";
5-
import { ModelSelect } from "./ModelSelect";
65
import { SessionTabs } from "./SessionTabs";
76
import { useIsMobile } from "../hooks/useIsMobile";
87
import { dlog } from "../debug-log";
@@ -165,10 +164,14 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
165164
const [imageUploading, setImageUploading] = useState(false);
166165
const [dragOver, setDragOver] = useState(false);
167166
const [quotedMessage, setQuotedMessage] = useState<ChatMessage | null>(null);
167+
const [modelOpen, setModelOpen] = useState(false);
168168
const messagesEndRef = useRef<HTMLDivElement>(null);
169169
const inputRef = useRef<HTMLTextAreaElement>(null);
170170
const fileInputRef = useRef<HTMLInputElement>(null);
171171
const dropZoneRef = useRef<HTMLDivElement>(null);
172+
const modelRef = useRef<HTMLDivElement>(null);
173+
const tabBarRef = useRef<HTMLDivElement>(null);
174+
const [tabBarWidth, setTabBarWidth] = useState(0);
172175

173176
const sessionKey = state.selectedSessionKey;
174177

@@ -202,6 +205,32 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
202205
}
203206
}, [sessionKey, isMobile]);
204207

208+
// Close model dropdown on outside click
209+
useEffect(() => {
210+
if (!modelOpen) return;
211+
const handler = (e: MouseEvent) => {
212+
if (modelRef.current && !modelRef.current.contains(e.target as Node)) {
213+
setModelOpen(false);
214+
}
215+
};
216+
document.addEventListener("mousedown", handler);
217+
return () => document.removeEventListener("mousedown", handler);
218+
}, [modelOpen]);
219+
220+
// Measure tab bar width for adaptive model display
221+
useEffect(() => {
222+
const el = tabBarRef.current;
223+
if (!el) return;
224+
const observer = new ResizeObserver((entries) => {
225+
for (const entry of entries) {
226+
setTabBarWidth(entry.contentRect.width);
227+
}
228+
});
229+
observer.observe(el);
230+
setTabBarWidth(el.clientWidth);
231+
return () => observer.disconnect();
232+
}, []);
233+
205234
// Restore per-session model from localStorage when session changes
206235
useEffect(() => {
207236
if (!sessionKey) return;
@@ -219,6 +248,15 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
219248

220249
const currentModel = state.sessionModel ?? state.defaultModel;
221250

251+
const modelDisplayText = useMemo(() => {
252+
if (!currentModel) return null;
253+
if (tabBarWidth >= 500) {
254+
const slash = currentModel.lastIndexOf("/");
255+
return slash >= 0 ? currentModel.substring(slash + 1) : currentModel;
256+
}
257+
return null;
258+
}, [currentModel, tabBarWidth]);
259+
222260
const handleModelChange = useCallback((modelId: string) => {
223261
if (!modelId || !sessionKey || modelId === currentModel) return;
224262

@@ -563,8 +601,6 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
563601
});
564602
}, [sessionKey, state.streamingRunId, state.streamingThreadId, state.user?.id, sendMessage, dispatch]);
565603

566-
const isStreaming = !!state.streamingRunId && !state.streamingThreadId;
567-
568604
const selectedAgent = state.agents.find((a) => a.id === state.selectedAgentId);
569605
const channelName = selectedAgent?.name ?? "channel";
570606
const channelId = selectedAgent?.channelId ?? null;
@@ -619,56 +655,97 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
619655
{/* Channel header — hidden on mobile (MobileLayout already shows channel name) */}
620656
{!isMobile && (
621657
<div
622-
className="flex items-center justify-between px-3 sm:px-5 gap-2 flex-shrink-0"
658+
className="flex items-center px-3 sm:px-5 gap-2 flex-shrink-0"
623659
style={{
624660
height: 44,
625661
borderBottom: "1px solid var(--border)",
626662
}}
627663
>
628-
<div className="flex items-center gap-2 min-w-0">
629-
<span className="text-h1 truncate" style={{ color: "var(--text-primary)" }}>
630-
# {channelName}
664+
<span className="text-h1 truncate" style={{ color: "var(--text-primary)" }}>
665+
# {channelName}
666+
</span>
667+
{selectedAgent && !selectedAgent.isDefault && (
668+
<span className="text-caption hidden sm:inline flex-shrink-0" style={{ color: "var(--text-secondary)" }}>
669+
— custom channel
631670
</span>
632-
{selectedAgent && !selectedAgent.isDefault && (
633-
<span className="text-caption hidden sm:inline flex-shrink-0" style={{ color: "var(--text-secondary)" }}>
634-
— custom channel
635-
</span>
636-
)}
637-
</div>
638-
<div className="flex items-center gap-1.5 flex-shrink-0">
639-
<svg className="w-3.5 h-3.5 hidden sm:block" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} style={{ color: "var(--text-muted)" }}>
640-
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456z" />
641-
</svg>
642-
<ModelSelect
643-
value={currentModel ?? ""}
644-
onChange={handleModelChange}
645-
models={state.models}
646-
disabled={!state.openclawConnected}
647-
placeholder="No model"
648-
compact
649-
/>
650-
</div>
671+
)}
651672
</div>
652673
)}
653674

654-
{/* Session tabs + model selector (mobile: inline with tabs) */}
675+
{/* Session tabs + adaptive model selector (all screen sizes) */}
655676
{showSessionTabs && (
656-
<div className="flex items-center flex-shrink-0">
677+
<div ref={tabBarRef} className="flex items-stretch flex-shrink-0">
657678
<div className="flex-1 min-w-0">
658679
<SessionTabs channelId={channelId} />
659680
</div>
660-
{isMobile && (
661-
<div className="flex-shrink-0 pr-2" style={{ borderBottom: "1px solid var(--border)" }}>
662-
<ModelSelect
663-
value={currentModel ?? ""}
664-
onChange={handleModelChange}
665-
models={state.models}
666-
disabled={!state.openclawConnected}
667-
placeholder="No model"
668-
compact
669-
/>
670-
</div>
671-
)}
681+
<div
682+
ref={modelRef}
683+
className="relative flex-shrink-0 flex items-center pr-2"
684+
style={{ borderBottom: "1px solid var(--border)" }}
685+
>
686+
<button
687+
onClick={() => setModelOpen((v) => !v)}
688+
disabled={!state.openclawConnected}
689+
className="flex items-center gap-1.5 px-2 h-8 rounded-md transition-colors text-caption"
690+
style={{
691+
color: currentModel ? "var(--text-primary)" : "var(--text-muted)",
692+
opacity: !state.openclawConnected ? 0.5 : 1,
693+
cursor: !state.openclawConnected ? "not-allowed" : "pointer",
694+
fontFamily: "var(--font-mono)",
695+
}}
696+
title={currentModel || "Select model"}
697+
>
698+
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
699+
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456z" />
700+
</svg>
701+
{modelDisplayText && (
702+
<span
703+
className="block overflow-hidden whitespace-nowrap max-w-[200px]"
704+
style={{ direction: "rtl", textOverflow: "ellipsis" }}
705+
>{modelDisplayText}</span>
706+
)}
707+
</button>
708+
{modelOpen && (
709+
<div
710+
className="absolute right-0 top-full mt-1 z-50 rounded-md shadow-lg py-1 min-w-[220px] max-h-[300px] overflow-y-auto"
711+
style={{
712+
background: "var(--bg-surface)",
713+
border: "1px solid var(--border)",
714+
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
715+
}}
716+
>
717+
{state.models.map((m) => (
718+
<button
719+
key={m.id}
720+
onClick={() => {
721+
handleModelChange(m.id);
722+
setModelOpen(false);
723+
}}
724+
className="w-full text-left px-3 py-2 text-caption transition-colors"
725+
style={{
726+
color: m.id === currentModel ? "var(--text-primary)" : "var(--text-secondary)",
727+
background: m.id === currentModel ? "var(--bg-hover)" : "transparent",
728+
fontFamily: "var(--font-mono)",
729+
}}
730+
onMouseEnter={(e) => { e.currentTarget.style.background = "var(--bg-hover)"; }}
731+
onMouseLeave={(e) => {
732+
e.currentTarget.style.background = m.id === currentModel ? "var(--bg-hover)" : "transparent";
733+
}}
734+
>
735+
{m.id === currentModel && (
736+
<span className="mr-1.5" style={{ color: "var(--accent)" }}>&#10003;</span>
737+
)}
738+
{m.id}
739+
</button>
740+
))}
741+
{state.models.length === 0 && (
742+
<div className="px-3 py-2 text-caption" style={{ color: "var(--text-muted)" }}>
743+
No models available
744+
</div>
745+
)}
746+
</div>
747+
)}
748+
</div>
672749
</div>
673750
)}
674751

@@ -865,38 +942,20 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
865942
</button>
866943
</div>
867944

868-
{/* Send / Stop button */}
869-
{isStreaming ? (
870-
<button
871-
onClick={handleStop}
872-
className="px-3 py-1.5 rounded-sm text-caption font-bold text-white transition-colors"
873-
style={{ background: "#e74c3c" }}
874-
onMouseEnter={(e) => { e.currentTarget.style.background = "#c0392b"; }}
875-
onMouseLeave={(e) => { e.currentTarget.style.background = "#e74c3c"; }}
876-
title="Stop generating"
877-
>
878-
<div className="flex items-center gap-1.5">
879-
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
880-
<rect x="6" y="6" width="12" height="12" rx="2" />
881-
</svg>
882-
Stop
883-
</div>
884-
</button>
885-
) : (
886-
<button
887-
onClick={handleSend}
888-
disabled={!input.trim() && !pendingImage}
889-
className="px-3 py-1.5 rounded-sm text-caption font-bold text-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
890-
style={{ background: "var(--bg-active)" }}
891-
>
892-
<div className="flex items-center gap-1.5">
893-
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
894-
<path strokeLinecap="round" strokeLinejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
895-
</svg>
896-
Send
897-
</div>
898-
</button>
899-
)}
945+
{/* Send button */}
946+
<button
947+
onClick={handleSend}
948+
disabled={!input.trim() && !pendingImage}
949+
className="px-3 py-1.5 rounded-sm text-caption font-bold text-white disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
950+
style={{ background: "var(--bg-active)" }}
951+
>
952+
<div className="flex items-center gap-1.5">
953+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
954+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
955+
</svg>
956+
Send
957+
</div>
958+
</button>
900959
</div>
901960
</div>
902961
</div>

0 commit comments

Comments
 (0)