botschat 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +2 -2
  2. package/package.json +4 -1
  3. package/packages/api/package.json +2 -1
  4. package/packages/api/src/do/connection-do.ts +128 -33
  5. package/packages/api/src/index.ts +103 -6
  6. package/packages/api/src/routes/auth.ts +123 -29
  7. package/packages/api/src/routes/pairing.ts +14 -1
  8. package/packages/api/src/routes/setup.ts +70 -24
  9. package/packages/api/src/routes/upload.ts +12 -8
  10. package/packages/api/src/utils/auth.ts +212 -43
  11. package/packages/api/src/utils/id.ts +30 -14
  12. package/packages/api/src/utils/rate-limit.ts +73 -0
  13. package/packages/plugin/dist/src/channel.js +9 -3
  14. package/packages/plugin/dist/src/channel.js.map +1 -1
  15. package/packages/plugin/package.json +2 -2
  16. package/packages/web/dist/assets/{index-DuGeoFJT.css → index-BST9bfvT.css} +1 -1
  17. package/packages/web/dist/assets/index-Da18EnTa.js +851 -0
  18. package/packages/web/dist/botschat-icon.svg +4 -0
  19. package/packages/web/dist/index.html +23 -3
  20. package/packages/web/dist/manifest.json +24 -0
  21. package/packages/web/dist/sw.js +40 -0
  22. package/packages/web/index.html +21 -1
  23. package/packages/web/src/App.tsx +241 -96
  24. package/packages/web/src/api.ts +63 -3
  25. package/packages/web/src/components/ChatWindow.tsx +11 -11
  26. package/packages/web/src/components/ConnectionSettings.tsx +475 -0
  27. package/packages/web/src/components/CronDetail.tsx +475 -235
  28. package/packages/web/src/components/CronSidebar.tsx +1 -1
  29. package/packages/web/src/components/DebugLogPanel.tsx +116 -3
  30. package/packages/web/src/components/IconRail.tsx +56 -16
  31. package/packages/web/src/components/JobList.tsx +2 -6
  32. package/packages/web/src/components/LoginPage.tsx +126 -103
  33. package/packages/web/src/components/MobileLayout.tsx +480 -0
  34. package/packages/web/src/components/OnboardingPage.tsx +7 -16
  35. package/packages/web/src/components/ResizeHandle.tsx +34 -0
  36. package/packages/web/src/components/Sidebar.tsx +1 -1
  37. package/packages/web/src/components/TaskBar.tsx +2 -2
  38. package/packages/web/src/components/ThreadPanel.tsx +2 -5
  39. package/packages/web/src/hooks/useIsMobile.ts +27 -0
  40. package/packages/web/src/index.css +59 -0
  41. package/packages/web/src/main.tsx +9 -0
  42. package/packages/web/src/store.ts +12 -5
  43. package/packages/web/src/ws.ts +2 -0
  44. package/scripts/dev.sh +13 -13
  45. package/wrangler.toml +3 -1
  46. package/packages/web/dist/assets/index-DyzTR_Y4.js +0 -847
@@ -1,10 +1,13 @@
1
1
  import React, { useEffect, useCallback, useState, useRef } from "react";
2
2
  import ReactMarkdown from "react-markdown";
3
3
  import remarkGfm from "remark-gfm";
4
+ import { Group, Panel, useDefaultLayout } from "react-resizable-panels";
4
5
  import { useAppState, useAppDispatch } from "../store";
5
6
  import { jobsApi, tasksApi } from "../api";
6
7
  import { ModelSelect } from "./ModelSelect";
7
8
  import { ScheduleEditor, ScheduleDisplay } from "./ScheduleEditor";
9
+ import { ResizeHandle } from "./ResizeHandle";
10
+ import { useIsMobile } from "../hooks/useIsMobile";
8
11
  import { dlog } from "../debug-log";
9
12
 
10
13
  function relativeTime(ts: number): string {
@@ -49,6 +52,7 @@ function statusColors(status: string): { bg: string; fg: string } {
49
52
  export function CronDetail() {
50
53
  const state = useAppState();
51
54
  const dispatch = useAppDispatch();
55
+ const isMobile = useIsMobile();
52
56
 
53
57
  const task = state.cronTasks.find((t) => t.id === state.selectedCronTaskId);
54
58
 
@@ -302,7 +306,7 @@ export function CronDetail() {
302
306
 
303
307
  if (!task) {
304
308
  return (
305
- <div className="flex-1 flex items-center justify-center" style={{ background: "var(--bg-surface)" }}>
309
+ <div className="flex-1 h-full flex items-center justify-center" style={{ background: "var(--bg-surface)" }}>
306
310
  <div className="text-center">
307
311
  <svg
308
312
  className="w-16 h-16 mx-auto mb-4"
@@ -329,15 +333,15 @@ export function CronDetail() {
329
333
  const channel = state.channels.find((c) => c.id === task.channelId);
330
334
 
331
335
  return (
332
- <div className="flex-1 flex flex-col min-w-0" style={{ background: "var(--bg-surface)" }}>
336
+ <div className="flex-1 h-full flex flex-col min-w-0" style={{ background: "var(--bg-surface)" }}>
333
337
  {/* ---- Header ---- */}
334
338
  <div
335
- className="flex items-center justify-between px-5 py-3"
339
+ className="flex flex-wrap items-center justify-between gap-2 px-4 sm:px-5 py-2 sm:py-3 flex-shrink-0"
336
340
  style={{ borderBottom: "1px solid var(--border)" }}
337
341
  >
338
- <div className="flex items-center gap-3 min-w-0">
342
+ <div className="flex items-center gap-2 sm:gap-3 min-w-0 flex-1">
339
343
  <svg
340
- className="w-5 h-5 flex-shrink-0"
344
+ className="w-5 h-5 flex-shrink-0 hidden sm:block"
341
345
  fill="none"
342
346
  viewBox="0 0 24 24"
343
347
  stroke="currentColor"
@@ -348,18 +352,18 @@ export function CronDetail() {
348
352
  </svg>
349
353
 
350
354
  {editingField === "name" ? (
351
- <div className="flex items-center gap-2">
355
+ <div className="flex items-center gap-2 min-w-0">
352
356
  <input
353
357
  ref={editRef as React.RefObject<HTMLInputElement>}
354
358
  value={editValue}
355
359
  onChange={(e) => setEditValue(e.target.value)}
356
360
  onKeyDown={handleKeyDown}
357
- className="text-h2 font-bold px-2 py-0.5 rounded-sm focus:outline-none"
361
+ className="text-h2 font-bold px-2 py-0.5 rounded-sm focus:outline-none min-w-0 w-full"
358
362
  style={{
359
363
  background: "var(--bg-hover)",
360
364
  color: "var(--text-primary)",
361
365
  border: "1px solid var(--bg-active)",
362
- minWidth: 200,
366
+ maxWidth: 280,
363
367
  }}
364
368
  />
365
369
  <SaveCancelButtons saving={saving} onSave={saveEdit} onCancel={cancelEdit} />
@@ -385,12 +389,12 @@ export function CronDetail() {
385
389
  )}
386
390
  </div>
387
391
 
388
- <div className="flex items-center gap-2 flex-shrink-0">
392
+ <div className="flex items-center gap-1.5 sm:gap-2 flex-shrink-0">
389
393
  {/* Run Now button */}
390
394
  <button
391
395
  onClick={handleRunNow}
392
396
  disabled={running}
393
- className="flex items-center gap-1.5 px-3 py-1.5 text-caption rounded-sm transition-colors disabled:opacity-50"
397
+ className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1 sm:py-1.5 text-caption rounded-sm transition-colors disabled:opacity-50"
394
398
  style={{
395
399
  background: "rgba(29,155,209,0.15)",
396
400
  color: "var(--text-link)",
@@ -400,13 +404,13 @@ export function CronDetail() {
400
404
  <svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
401
405
  <path d="M8 5v14l11-7z" />
402
406
  </svg>
403
- {running ? "Running..." : "Run Now"}
407
+ <span className="hidden sm:inline">{running ? "Running..." : "Run Now"}</span>
404
408
  </button>
405
409
 
406
410
  {/* Delete button */}
407
411
  <button
408
412
  onClick={() => setShowDeleteConfirm(true)}
409
- className="p-1.5 rounded-sm transition-colors hover:bg-[--bg-hover]"
413
+ className="p-1 sm:p-1.5 rounded-sm transition-colors hover:bg-[--bg-hover]"
410
414
  style={{ color: "var(--text-muted)" }}
411
415
  title="Delete task"
412
416
  >
@@ -418,14 +422,14 @@ export function CronDetail() {
418
422
  {/* Enable/Disable toggle */}
419
423
  <button
420
424
  onClick={handleToggleEnabled}
421
- className="flex items-center gap-2 px-3 py-1.5 text-caption rounded-sm transition-colors"
425
+ className="flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-1 sm:py-1.5 text-caption rounded-sm transition-colors"
422
426
  style={{
423
427
  background: task.enabled ? "rgba(43,172,118,0.15)" : "rgba(107,111,118,0.15)",
424
428
  color: task.enabled ? "var(--accent-green)" : "var(--text-muted)",
425
429
  }}
426
430
  >
427
431
  <div
428
- className="w-7 h-4 rounded-full relative transition-colors"
432
+ className="w-7 h-4 rounded-full relative transition-colors flex-shrink-0"
429
433
  style={{ background: task.enabled ? "var(--accent-green)" : "var(--text-muted)" }}
430
434
  >
431
435
  <div
@@ -433,7 +437,7 @@ export function CronDetail() {
433
437
  style={{ left: task.enabled ? 14 : 2 }}
434
438
  />
435
439
  </div>
436
- {task.enabled ? "Enabled" : "Disabled"}
440
+ <span className="hidden sm:inline">{task.enabled ? "Enabled" : "Disabled"}</span>
437
441
  </button>
438
442
  </div>
439
443
  </div>
@@ -441,15 +445,15 @@ export function CronDetail() {
441
445
  {/* ---- Delete confirmation ---- */}
442
446
  {showDeleteConfirm && (
443
447
  <div
444
- className="px-5 py-3 flex items-center justify-between"
448
+ className="px-4 sm:px-5 py-2 sm:py-3 flex flex-wrap items-center justify-between gap-2"
445
449
  style={{ background: "rgba(224,30,90,0.08)", borderBottom: "1px solid var(--border)" }}
446
450
  >
447
- <div className="flex items-center gap-2">
448
- <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} style={{ color: "var(--accent-red)" }}>
451
+ <div className="flex items-center gap-2 min-w-0">
452
+ <svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} style={{ color: "var(--accent-red)" }}>
449
453
  <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
450
454
  </svg>
451
455
  <span className="text-caption" style={{ color: "var(--accent-red)" }}>
452
- Delete "{task.name}"? This will remove the task, all execution history, and the OpenClaw cron job.
456
+ Delete "{task.name}"?
453
457
  </span>
454
458
  </div>
455
459
  <div className="flex items-center gap-2 flex-shrink-0">
@@ -472,248 +476,484 @@ export function CronDetail() {
472
476
  </div>
473
477
  )}
474
478
 
475
- {/* ---- Info section (collapsible) ---- */}
476
- <div style={{ borderBottom: "1px solid var(--border)" }}>
477
- <button
478
- className="w-full flex items-center gap-2 px-5 py-2 text-tiny uppercase tracking-wider hover:bg-[--bg-hover] transition-colors"
479
- style={{ color: "var(--text-muted)" }}
480
- onClick={() => setInfoExpanded(!infoExpanded)}
481
- >
482
- <svg
483
- className={`w-3 h-3 transition-transform ${infoExpanded ? "rotate-0" : "-rotate-90"}`}
484
- fill="none"
485
- viewBox="0 0 24 24"
486
- stroke="currentColor"
487
- strokeWidth={2}
488
- >
489
- <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
490
- </svg>
491
- Task Details
492
- </button>
479
+ {/* ---- Info section + Content area: vertically resizable on desktop, stacked on mobile ---- */}
480
+ <CronInfoAndContent
481
+ task={task}
482
+ channel={channel}
483
+ state={state}
484
+ isMobile={isMobile}
485
+ infoExpanded={infoExpanded}
486
+ setInfoExpanded={setInfoExpanded}
487
+ editingField={editingField}
488
+ editValue={editValue}
489
+ setEditValue={setEditValue}
490
+ editRef={editRef}
491
+ handleKeyDown={handleKeyDown}
492
+ saving={saving}
493
+ saveEdit={saveEdit}
494
+ cancelEdit={cancelEdit}
495
+ startEdit={startEdit}
496
+ handleModelSelectChange={handleModelSelectChange}
497
+ handleSelectJob={handleSelectJob}
498
+ />
499
+ </div>
500
+ );
501
+ }
493
502
 
494
- {infoExpanded && (
495
- <div className="px-5 pb-4 space-y-4">
496
- {/* Row 1: Schedule (wider) + Model + Channel + Status */}
497
- {editingField === "schedule" ? (
498
- /* Schedule editing takes full width */
499
- <div>
500
- <InfoField label="Schedule">
501
- <ScheduleEditor
502
- value={editValue}
503
- onChange={setEditValue}
504
- onSave={saveEdit}
505
- onCancel={cancelEdit}
506
- saving={saving}
507
- />
508
- </InfoField>
509
- </div>
510
- ) : (
511
- <div className="grid grid-cols-4 gap-4">
512
- <InfoField label="Schedule">
513
- <ScheduleDisplay
514
- schedule={task.schedule}
515
- onClick={() => startEdit("schedule")}
516
- />
517
- </InfoField>
518
-
519
- <InfoField label="Model">
520
- <ModelSelect
521
- value={task.model ?? ""}
522
- onChange={handleModelSelectChange}
523
- models={state.models}
524
- placeholder="Default"
525
- />
526
- </InfoField>
503
+ // ---- Info section + Content area (vertical resizable split) ----
527
504
 
528
- <InfoField label="Channel">
529
- <span className="text-body" style={{ color: "var(--text-primary)" }}>
530
- {channel?.name ?? "Default"}
531
- </span>
532
- </InfoField>
533
-
534
- <InfoField label="Status">
535
- <div className="flex items-center gap-2">
536
- <div
537
- className="w-2 h-2 rounded-full"
538
- style={{ background: task.enabled ? "var(--accent-green)" : "var(--accent-yellow)" }}
539
- />
540
- <span className="text-body" style={{ color: "var(--text-primary)" }}>
541
- {task.enabled ? "Active" : "Paused"}
542
- </span>
543
- </div>
544
- </InfoField>
545
- </div>
546
- )}
505
+ function CronInfoAndContent({
506
+ task,
507
+ channel,
508
+ state,
509
+ isMobile,
510
+ infoExpanded,
511
+ setInfoExpanded,
512
+ editingField,
513
+ editValue,
514
+ setEditValue,
515
+ editRef,
516
+ handleKeyDown,
517
+ saving,
518
+ saveEdit,
519
+ cancelEdit,
520
+ startEdit,
521
+ handleModelSelectChange,
522
+ handleSelectJob,
523
+ }: {
524
+ task: any;
525
+ channel: any;
526
+ state: any;
527
+ isMobile: boolean;
528
+ infoExpanded: boolean;
529
+ setInfoExpanded: (v: boolean) => void;
530
+ editingField: "name" | "schedule" | "instructions" | null;
531
+ editValue: string;
532
+ setEditValue: (v: string) => void;
533
+ editRef: React.RefObject<HTMLInputElement | HTMLTextAreaElement | null>;
534
+ handleKeyDown: (e: React.KeyboardEvent) => void;
535
+ saving: boolean;
536
+ saveEdit: () => void;
537
+ cancelEdit: () => void;
538
+ startEdit: (field: "name" | "schedule" | "instructions") => void;
539
+ handleModelSelectChange: (modelId: string) => Promise<void>;
540
+ handleSelectJob: (jobId: string) => void;
541
+ }) {
542
+ const cronDetailVertLayout = useDefaultLayout({ id: "botschat-cron-detail-v" });
547
543
 
548
- {/* Row 2: Cron Job ID + Created + Updated */}
549
- <div className="grid grid-cols-3 gap-4">
550
- <InfoField label="Cron Job ID">
551
- <span className="text-caption font-mono" style={{ color: "var(--text-secondary)" }}>
552
- {task.openclawCronJobId ?? "N/A"}
553
- </span>
554
- </InfoField>
544
+ // Mobile drag-to-resize state
545
+ const [mobileInfoPct, setMobileInfoPct] = useState(40);
546
+ const mobileContainerRef = useRef<HTMLDivElement>(null);
547
+ const dragRef = useRef({ startY: 0, startPct: 40 });
555
548
 
556
- <InfoField label="Created">
557
- <span className="text-caption" style={{ color: "var(--text-secondary)" }}>
558
- {task.createdAt ? formatTimestamp(task.createdAt) : "N/A"}
559
- </span>
560
- </InfoField>
549
+ const onDragHandleTouchStart = useCallback((e: React.TouchEvent) => {
550
+ e.preventDefault();
551
+ dragRef.current.startY = e.touches[0].clientY;
552
+ dragRef.current.startPct = mobileInfoPct;
553
+ const onMove = (ev: TouchEvent) => {
554
+ ev.preventDefault();
555
+ if (!mobileContainerRef.current) return;
556
+ const h = mobileContainerRef.current.getBoundingClientRect().height;
557
+ const delta = ((ev.touches[0].clientY - dragRef.current.startY) / h) * 100;
558
+ setMobileInfoPct(Math.max(10, Math.min(70, dragRef.current.startPct + delta)));
559
+ };
560
+ const onEnd = () => {
561
+ document.removeEventListener("touchmove", onMove);
562
+ document.removeEventListener("touchend", onEnd);
563
+ };
564
+ document.addEventListener("touchmove", onMove, { passive: false });
565
+ document.addEventListener("touchend", onEnd);
566
+ }, [mobileInfoPct]);
561
567
 
562
- <InfoField label="Updated">
563
- <span className="text-caption" style={{ color: "var(--text-secondary)" }}>
564
- {task.updatedAt ? formatTimestamp(task.updatedAt) : "N/A"}
565
- </span>
568
+ const onDragHandleMouseDown = useCallback((e: React.MouseEvent) => {
569
+ e.preventDefault();
570
+ dragRef.current.startY = e.clientY;
571
+ dragRef.current.startPct = mobileInfoPct;
572
+ const onMove = (ev: MouseEvent) => {
573
+ if (!mobileContainerRef.current) return;
574
+ const h = mobileContainerRef.current.getBoundingClientRect().height;
575
+ const delta = ((ev.clientY - dragRef.current.startY) / h) * 100;
576
+ setMobileInfoPct(Math.max(10, Math.min(70, dragRef.current.startPct + delta)));
577
+ };
578
+ const onEnd = () => {
579
+ document.removeEventListener("mousemove", onMove);
580
+ document.removeEventListener("mouseup", onEnd);
581
+ };
582
+ document.addEventListener("mousemove", onMove);
583
+ document.addEventListener("mouseup", onEnd);
584
+ }, [mobileInfoPct]);
585
+
586
+ // Shared info section content
587
+ const infoSection = (
588
+ <div className="h-full flex flex-col">
589
+ <button
590
+ className="w-full flex items-center gap-2 px-4 sm:px-5 py-2 text-tiny uppercase tracking-wider hover:bg-[--bg-hover] transition-colors flex-shrink-0"
591
+ style={{ color: "var(--text-muted)" }}
592
+ onClick={() => setInfoExpanded(!infoExpanded)}
593
+ >
594
+ <svg
595
+ className={`w-3 h-3 transition-transform ${infoExpanded ? "rotate-0" : "-rotate-90"}`}
596
+ fill="none"
597
+ viewBox="0 0 24 24"
598
+ stroke="currentColor"
599
+ strokeWidth={2}
600
+ >
601
+ <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
602
+ </svg>
603
+ Task Details
604
+ </button>
605
+
606
+ {infoExpanded && (
607
+ <div className="flex-1 overflow-y-auto px-4 sm:px-5 pb-4 space-y-4">
608
+ {/* Row 1: Schedule (wider) + Model + Channel + Status */}
609
+ {editingField === "schedule" ? (
610
+ <div>
611
+ <InfoField label="Schedule">
612
+ <ScheduleEditor
613
+ value={editValue}
614
+ onChange={setEditValue}
615
+ onSave={saveEdit}
616
+ onCancel={cancelEdit}
617
+ saving={saving}
618
+ />
566
619
  </InfoField>
567
620
  </div>
621
+ ) : (
622
+ <div className="grid grid-cols-2 sm:grid-cols-4 gap-3 sm:gap-4">
623
+ <InfoField label="Schedule">
624
+ <ScheduleDisplay
625
+ schedule={task.schedule}
626
+ onClick={() => startEdit("schedule")}
627
+ />
628
+ </InfoField>
568
629
 
569
- {/* Row 3: Prompt / Instructions (full width) */}
570
- <div>
571
- <div className="flex items-center justify-between mb-1">
572
- <span className="text-tiny uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>
573
- Prompt / Instructions
630
+ <InfoField label="Model">
631
+ <ModelSelect
632
+ value={task.model ?? ""}
633
+ onChange={handleModelSelectChange}
634
+ models={state.models}
635
+ placeholder="Default"
636
+ />
637
+ </InfoField>
638
+
639
+ <InfoField label="Channel">
640
+ <span className="text-body" style={{ color: "var(--text-primary)" }}>
641
+ {channel?.name ?? "Default"}
574
642
  </span>
575
- {editingField !== "instructions" && (
576
- <button
577
- onClick={() => startEdit("instructions")}
578
- className="text-tiny px-2 py-0.5 rounded-sm transition-colors hover:bg-[--bg-hover]"
579
- style={{ color: "var(--text-link)" }}
580
- >
581
- Edit
582
- </button>
583
- )}
584
- </div>
643
+ </InfoField>
585
644
 
586
- {editingField === "instructions" ? (
587
- <div>
588
- <textarea
589
- ref={editRef as React.RefObject<HTMLTextAreaElement>}
590
- value={editValue}
591
- onChange={(e) => setEditValue(e.target.value)}
592
- onKeyDown={(e) => {
593
- if (e.key === "Escape") cancelEdit();
594
- if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
595
- e.preventDefault();
596
- saveEdit();
597
- }
598
- }}
599
- placeholder="Enter the prompt or instructions for this cron task..."
600
- rows={6}
601
- className="w-full text-caption p-3 rounded-md resize-y focus:outline-none"
602
- style={{
603
- background: "var(--bg-hover)",
604
- color: "var(--text-primary)",
605
- border: "1px solid var(--bg-active)",
606
- minHeight: 80,
607
- maxHeight: 300,
608
- }}
645
+ <InfoField label="Status">
646
+ <div className="flex items-center gap-2">
647
+ <div
648
+ className="w-2 h-2 rounded-full"
649
+ style={{ background: task.enabled ? "var(--accent-green)" : "var(--accent-yellow)" }}
609
650
  />
610
- <div className="flex items-center justify-between mt-2">
611
- <span className="text-tiny" style={{ color: "var(--text-muted)" }}>
612
- Cmd/Ctrl+Enter to save, Esc to cancel
613
- </span>
614
- <SaveCancelButtons saving={saving} onSave={saveEdit} onCancel={cancelEdit} />
615
- </div>
651
+ <span className="text-body" style={{ color: "var(--text-primary)" }}>
652
+ {task.enabled ? "Active" : "Paused"}
653
+ </span>
616
654
  </div>
617
- ) : (
618
- <div
619
- className="text-caption p-3 rounded-md whitespace-pre-wrap cursor-pointer hover:border-[--text-muted] transition-colors"
620
- style={{
621
- background: "var(--bg-hover)",
622
- color: task.instructions ? "var(--text-primary)" : "var(--text-muted)",
623
- border: "1px solid transparent",
624
- minHeight: 48,
625
- }}
655
+ </InfoField>
656
+ </div>
657
+ )}
658
+
659
+ {/* Row 2: Cron Job ID + Created + Updated */}
660
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-3 sm:gap-4">
661
+ <InfoField label="Cron Job ID">
662
+ <span className="text-caption font-mono" style={{ color: "var(--text-secondary)" }}>
663
+ {task.openclawCronJobId ?? "N/A"}
664
+ </span>
665
+ </InfoField>
666
+
667
+ <InfoField label="Created">
668
+ <span className="text-caption" style={{ color: "var(--text-secondary)" }}>
669
+ {task.createdAt ? formatTimestamp(task.createdAt) : "N/A"}
670
+ </span>
671
+ </InfoField>
672
+
673
+ <InfoField label="Updated">
674
+ <span className="text-caption" style={{ color: "var(--text-secondary)" }}>
675
+ {task.updatedAt ? formatTimestamp(task.updatedAt) : "N/A"}
676
+ </span>
677
+ </InfoField>
678
+ </div>
679
+
680
+ {/* Row 3: Prompt / Instructions (full width) */}
681
+ <div>
682
+ <div className="flex items-center justify-between mb-1">
683
+ <span className="text-tiny uppercase tracking-wider" style={{ color: "var(--text-muted)" }}>
684
+ Prompt / Instructions
685
+ </span>
686
+ {editingField !== "instructions" && (
687
+ <button
626
688
  onClick={() => startEdit("instructions")}
627
- title="Click to edit"
689
+ className="text-tiny px-2 py-0.5 rounded-sm transition-colors hover:bg-[--bg-hover]"
690
+ style={{ color: "var(--text-link)" }}
628
691
  >
629
- {task.instructions || "No instructions set. Click to add a prompt for this cron task."}
630
- </div>
692
+ Edit
693
+ </button>
631
694
  )}
632
695
  </div>
696
+
697
+ {editingField === "instructions" ? (
698
+ <div>
699
+ <textarea
700
+ ref={editRef as React.RefObject<HTMLTextAreaElement>}
701
+ value={editValue}
702
+ onChange={(e) => setEditValue(e.target.value)}
703
+ onKeyDown={(e) => {
704
+ if (e.key === "Escape") cancelEdit();
705
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
706
+ e.preventDefault();
707
+ saveEdit();
708
+ }
709
+ }}
710
+ placeholder="Enter the prompt or instructions for this cron task..."
711
+ rows={6}
712
+ className="w-full text-caption p-3 rounded-md resize-y focus:outline-none"
713
+ style={{
714
+ background: "var(--bg-hover)",
715
+ color: "var(--text-primary)",
716
+ border: "1px solid var(--bg-active)",
717
+ minHeight: 80,
718
+ maxHeight: 300,
719
+ }}
720
+ />
721
+ <div className="flex items-center justify-between mt-2">
722
+ <span className="text-tiny" style={{ color: "var(--text-muted)" }}>
723
+ Cmd/Ctrl+Enter to save, Esc to cancel
724
+ </span>
725
+ <SaveCancelButtons saving={saving} onSave={saveEdit} onCancel={cancelEdit} />
726
+ </div>
727
+ </div>
728
+ ) : (
729
+ <div
730
+ className="text-caption p-3 rounded-md whitespace-pre-wrap cursor-pointer hover:border-[--text-muted] transition-colors"
731
+ style={{
732
+ background: "var(--bg-hover)",
733
+ color: task.instructions ? "var(--text-primary)" : "var(--text-muted)",
734
+ border: "1px solid transparent",
735
+ minHeight: 48,
736
+ }}
737
+ onClick={() => startEdit("instructions")}
738
+ title="Click to edit"
739
+ >
740
+ {task.instructions || "No instructions set. Click to add a prompt for this cron task."}
741
+ </div>
742
+ )}
633
743
  </div>
634
- )}
635
- </div>
744
+ </div>
745
+ )}
746
+ </div>
747
+ );
636
748
 
637
- {/* ---- Content area: job history + chat ---- */}
638
- <div className="flex-1 flex min-h-0">
639
- {/* Job list panel */}
749
+ // Mobile: vertical stack with draggable divider between info and content
750
+ if (isMobile) {
751
+ return (
752
+ <div ref={mobileContainerRef} className="flex-1 min-h-0 flex flex-col">
753
+ {/* Info section — draggable height when expanded, auto when collapsed */}
640
754
  <div
641
- className="overflow-y-auto flex-shrink-0"
755
+ className="flex-shrink-0 overflow-y-auto"
642
756
  style={{
643
- width: 220,
644
- borderRight: "1px solid var(--border)",
757
+ ...(infoExpanded ? { height: `${mobileInfoPct}%` } : {}),
758
+ borderBottom: "1px solid var(--border)",
645
759
  }}
646
760
  >
647
- <div className="px-3 py-2" style={{ borderBottom: "1px solid var(--border)" }}>
648
- <span className="text-tiny uppercase tracking-wider font-bold" style={{ color: "var(--text-muted)" }}>
649
- Execution History
650
- </span>
651
- <span className="text-tiny ml-1" style={{ color: "var(--text-muted)" }}>
652
- ({state.cronJobs.length})
653
- </span>
761
+ {infoSection}
762
+ </div>
763
+ {/* Drag handle — only shown when info section is expanded */}
764
+ {infoExpanded && (
765
+ <div
766
+ className="flex-shrink-0 flex items-center justify-center touch-none select-none"
767
+ style={{
768
+ height: 20,
769
+ cursor: "row-resize",
770
+ background: "var(--bg-surface)",
771
+ borderBottom: "1px solid var(--border)",
772
+ }}
773
+ onTouchStart={onDragHandleTouchStart}
774
+ onMouseDown={onDragHandleMouseDown}
775
+ >
776
+ <div
777
+ className="rounded-full"
778
+ style={{
779
+ width: 36,
780
+ height: 4,
781
+ background: "var(--text-muted)",
782
+ opacity: 0.4,
783
+ }}
784
+ />
654
785
  </div>
655
- {state.cronJobs.length === 0 ? (
656
- <div className="px-4 py-6 text-center">
657
- <p className="text-tiny" style={{ color: "var(--text-muted)" }}>
658
- No runs yet.
659
- </p>
660
- <p className="text-tiny mt-1" style={{ color: "var(--text-muted)" }}>
661
- Waiting for schedule...
662
- </p>
663
- </div>
664
- ) : (
665
- state.cronJobs.map((job, idx) => {
666
- const colors = statusColors(job.status);
667
- const displayNum = job.number || state.cronJobs.length - idx;
668
- const isSelected = state.selectedCronJobId === job.id;
669
- return (
670
- <button
671
- key={job.id}
672
- onClick={() => handleSelectJob(job.id)}
673
- className={`w-full text-left px-3 py-2 hover:bg-[--bg-hover] transition-colors ${
674
- isSelected ? "bg-[--bg-hover]" : ""
675
- }`}
676
- style={{
677
- borderBottom: "1px solid var(--border)",
678
- ...(isSelected ? { borderLeft: "3px solid var(--bg-active)" } : {}),
679
- }}
680
- >
681
- <div className="flex items-center justify-between">
682
- <span className="text-tiny font-mono" style={{ color: "var(--text-muted)" }}>
683
- #{displayNum}
684
- </span>
685
- <span
686
- className="text-tiny px-1.5 py-0.5 rounded-sm font-bold flex items-center gap-1"
687
- style={{ background: colors.bg, color: colors.fg }}
688
- >
689
- {job.status === "running" && (
690
- <span className="inline-block w-1.5 h-1.5 rounded-full animate-pulse" style={{ background: "var(--text-link)" }} />
691
- )}
692
- {statusLabel(job.status)}
693
- </span>
694
- </div>
695
- <div className="text-tiny mt-0.5" style={{ color: "var(--text-muted)" }}>
696
- {job.time}
697
- {job.durationMs != null && (
698
- <span className="ml-1">({(job.durationMs / 1000).toFixed(1)}s)</span>
699
- )}
700
- </div>
701
- {job.summary && (
702
- <div className="text-caption mt-1 truncate" style={{ color: "var(--text-secondary)" }}>
703
- {job.summary}
704
- </div>
705
- )}
706
- </button>
707
- );
708
- })
709
- )}
786
+ )}
787
+ {/* History + output — takes remaining space */}
788
+ <CronContentPanels cronJobs={state.cronJobs} selectedCronJobId={state.selectedCronJobId} handleSelectJob={handleSelectJob} mobile />
789
+ </div>
790
+ );
791
+ }
792
+
793
+ // Desktop: vertical resizable split between info and content
794
+ return (
795
+ <Group
796
+ orientation="vertical"
797
+ defaultLayout={cronDetailVertLayout.defaultLayout}
798
+ onLayoutChanged={cronDetailVertLayout.onLayoutChanged}
799
+ id="botschat-cron-detail-v"
800
+ className="flex-1 min-h-0"
801
+ >
802
+ {/* Info section panel */}
803
+ <Panel id="cron-info" defaultSize="40%" minSize="5%" maxSize="70%">
804
+ <div className="h-full" style={{ borderBottom: "1px solid var(--border)" }}>
805
+ {infoSection}
710
806
  </div>
807
+ </Panel>
711
808
 
712
- {/* Job output detail */}
713
- <JobOutputPanel jobs={state.cronJobs} selectedJobId={state.selectedCronJobId} />
809
+ <ResizeHandle direction="vertical" />
810
+
811
+ {/* Content area panel (job history + output) */}
812
+ <Panel id="cron-content-area">
813
+ <CronContentPanels cronJobs={state.cronJobs} selectedCronJobId={state.selectedCronJobId} handleSelectJob={handleSelectJob} mobile={false} />
814
+ </Panel>
815
+ </Group>
816
+ );
817
+ }
818
+
819
+ // ---- Cron content panels (split component for hook usage) ----
820
+
821
+ function CronContentPanels({
822
+ cronJobs,
823
+ selectedCronJobId,
824
+ handleSelectJob,
825
+ mobile,
826
+ }: {
827
+ cronJobs: Array<{ id: string; number: number; status: string; startedAt: number; finishedAt: number | null; durationMs: number | null; summary: string; time: string; sessionKey?: string }>;
828
+ selectedCronJobId: string | null;
829
+ handleSelectJob: (jobId: string) => void;
830
+ mobile: boolean;
831
+ }) {
832
+ const cronLayout = useDefaultLayout({ id: "botschat-cron-content" });
833
+ const [mobileShowOutput, setMobileShowOutput] = useState(false);
834
+
835
+ // On mobile, tapping a job → show output; navigate back to list via header
836
+ const handleMobileSelectJob = (jobId: string) => {
837
+ handleSelectJob(jobId);
838
+ setMobileShowOutput(true);
839
+ };
840
+
841
+ // --- Shared job list content ---
842
+ const jobListContent = (
843
+ <div className="overflow-y-auto h-full">
844
+ <div className="px-3 py-2" style={{ borderBottom: "1px solid var(--border)" }}>
845
+ <span className="text-tiny uppercase tracking-wider font-bold" style={{ color: "var(--text-muted)" }}>
846
+ Execution History
847
+ </span>
848
+ <span className="text-tiny ml-1" style={{ color: "var(--text-muted)" }}>
849
+ ({cronJobs.length})
850
+ </span>
714
851
  </div>
852
+ {cronJobs.length === 0 ? (
853
+ <div className="px-4 py-6 text-center">
854
+ <p className="text-tiny" style={{ color: "var(--text-muted)" }}>
855
+ No runs yet.
856
+ </p>
857
+ <p className="text-tiny mt-1" style={{ color: "var(--text-muted)" }}>
858
+ Waiting for schedule...
859
+ </p>
860
+ </div>
861
+ ) : (
862
+ cronJobs.map((job, idx) => {
863
+ const colors = statusColors(job.status);
864
+ const displayNum = job.number || cronJobs.length - idx;
865
+ const isSelected = selectedCronJobId === job.id;
866
+ return (
867
+ <button
868
+ key={job.id}
869
+ onClick={() => mobile ? handleMobileSelectJob(job.id) : handleSelectJob(job.id)}
870
+ className={`w-full text-left px-3 py-2 hover:bg-[--bg-hover] transition-colors ${
871
+ isSelected ? "bg-[--bg-hover]" : ""
872
+ }`}
873
+ style={{
874
+ borderBottom: "1px solid var(--border)",
875
+ ...(isSelected ? { borderLeft: "3px solid var(--bg-active)" } : {}),
876
+ }}
877
+ >
878
+ <div className="flex items-center justify-between">
879
+ <span className="text-tiny font-mono" style={{ color: "var(--text-muted)" }}>
880
+ #{displayNum}
881
+ </span>
882
+ <span
883
+ className="text-tiny px-1.5 py-0.5 rounded-sm font-bold flex items-center gap-1"
884
+ style={{ background: colors.bg, color: colors.fg }}
885
+ >
886
+ {job.status === "running" && (
887
+ <span className="inline-block w-1.5 h-1.5 rounded-full animate-pulse" style={{ background: "var(--text-link)" }} />
888
+ )}
889
+ {statusLabel(job.status)}
890
+ </span>
891
+ </div>
892
+ <div className="text-tiny mt-0.5" style={{ color: "var(--text-muted)" }}>
893
+ {job.time}
894
+ {job.durationMs != null && (
895
+ <span className="ml-1">({(job.durationMs / 1000).toFixed(1)}s)</span>
896
+ )}
897
+ </div>
898
+ {job.summary && (
899
+ <div className="text-caption mt-1 truncate" style={{ color: "var(--text-secondary)" }}>
900
+ {job.summary}
901
+ </div>
902
+ )}
903
+ </button>
904
+ );
905
+ })
906
+ )}
715
907
  </div>
716
908
  );
909
+
910
+ // --- Mobile: stack-based navigation (list ↔ output) ---
911
+ if (mobile) {
912
+ if (mobileShowOutput && selectedCronJobId) {
913
+ return (
914
+ <div className="flex-1 flex flex-col min-h-0">
915
+ {/* Back to list */}
916
+ <button
917
+ onClick={() => setMobileShowOutput(false)}
918
+ className="flex items-center gap-1 px-4 py-2 text-caption flex-shrink-0"
919
+ style={{ borderBottom: "1px solid var(--border)", color: "var(--text-link)" }}
920
+ >
921
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
922
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
923
+ </svg>
924
+ Back to History
925
+ </button>
926
+ <div className="flex-1 min-h-0">
927
+ <JobOutputPanel jobs={cronJobs} selectedJobId={selectedCronJobId} />
928
+ </div>
929
+ </div>
930
+ );
931
+ }
932
+ return <div className="flex-1 min-h-0">{jobListContent}</div>;
933
+ }
934
+
935
+ // --- Desktop: resizable horizontal panels ---
936
+ return (
937
+ <Group
938
+ orientation="horizontal"
939
+ defaultLayout={cronLayout.defaultLayout}
940
+ onLayoutChanged={cronLayout.onLayoutChanged}
941
+ id="botschat-cron-content"
942
+ className="flex-1 min-h-0"
943
+ >
944
+ {/* Job list panel */}
945
+ <Panel id="cron-jobs" defaultSize="20%" minSize="8%" maxSize="40%">
946
+ {jobListContent}
947
+ </Panel>
948
+
949
+ <ResizeHandle />
950
+
951
+ {/* Job output detail */}
952
+ <Panel id="cron-output">
953
+ <JobOutputPanel jobs={cronJobs} selectedJobId={selectedCronJobId} />
954
+ </Panel>
955
+ </Group>
956
+ );
717
957
  }
718
958
 
719
959
  // ---- Job output panel ----
@@ -771,7 +1011,7 @@ function JobOutputPanel({
771
1011
 
772
1012
  if (!job) {
773
1013
  return (
774
- <div className="flex-1 flex items-center justify-center" style={{ background: "var(--bg-surface)" }}>
1014
+ <div className="flex-1 h-full flex items-center justify-center" style={{ background: "var(--bg-surface)" }}>
775
1015
  <p className="text-caption" style={{ color: "var(--text-muted)" }}>
776
1016
  {jobs.length > 0 ? "Select a run to view output" : "No execution history yet"}
777
1017
  </p>
@@ -784,7 +1024,7 @@ function JobOutputPanel({
784
1024
  const blocks = job.summary ? parseMessageBlocks(job.summary) : [];
785
1025
 
786
1026
  return (
787
- <div className="flex-1 flex flex-col min-w-0" style={{ background: "var(--bg-surface)" }}>
1027
+ <div className="flex-1 h-full flex flex-col min-w-0" style={{ background: "var(--bg-surface)" }}>
788
1028
  {/* Job header bar */}
789
1029
  <div
790
1030
  className="flex items-center gap-3 px-5 py-2.5 flex-shrink-0"