palmier 0.7.7 → 0.7.9

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 (106) hide show
  1. package/README.md +1 -1
  2. package/dist/agents/agent.d.ts +3 -0
  3. package/dist/agents/agent.js +1 -1
  4. package/dist/agents/aider.d.ts +1 -0
  5. package/dist/agents/aider.js +1 -0
  6. package/dist/agents/claude.d.ts +1 -0
  7. package/dist/agents/claude.js +1 -0
  8. package/dist/agents/cline.d.ts +1 -0
  9. package/dist/agents/cline.js +1 -0
  10. package/dist/agents/codex.d.ts +1 -0
  11. package/dist/agents/codex.js +1 -0
  12. package/dist/agents/copilot.d.ts +1 -0
  13. package/dist/agents/copilot.js +1 -0
  14. package/dist/agents/cursor.d.ts +1 -0
  15. package/dist/agents/cursor.js +1 -0
  16. package/dist/agents/deepagents.d.ts +1 -0
  17. package/dist/agents/deepagents.js +1 -0
  18. package/dist/agents/droid.d.ts +1 -0
  19. package/dist/agents/droid.js +1 -0
  20. package/dist/agents/gemini.d.ts +1 -0
  21. package/dist/agents/gemini.js +1 -0
  22. package/dist/agents/goose.d.ts +1 -0
  23. package/dist/agents/goose.js +1 -0
  24. package/dist/agents/hermes.d.ts +1 -0
  25. package/dist/agents/hermes.js +1 -0
  26. package/dist/agents/kimi.d.ts +1 -0
  27. package/dist/agents/kimi.js +1 -0
  28. package/dist/agents/kiro.d.ts +1 -0
  29. package/dist/agents/kiro.js +1 -0
  30. package/dist/agents/openclaw.d.ts +1 -0
  31. package/dist/agents/openclaw.js +2 -2
  32. package/dist/agents/opencode.d.ts +1 -0
  33. package/dist/agents/opencode.js +1 -0
  34. package/dist/agents/qoder.d.ts +1 -0
  35. package/dist/agents/qoder.js +1 -0
  36. package/dist/agents/qwen.d.ts +1 -0
  37. package/dist/agents/qwen.js +1 -0
  38. package/dist/commands/pair.js +2 -2
  39. package/dist/mcp-tools.d.ts +2 -0
  40. package/dist/mcp-tools.js +20 -9
  41. package/dist/pending-requests.d.ts +30 -8
  42. package/dist/pending-requests.js +28 -15
  43. package/dist/platform/linux.js +11 -8
  44. package/dist/platform/windows.d.ts +5 -6
  45. package/dist/platform/windows.js +15 -12
  46. package/dist/pwa/assets/index-FP1Mipr6.js +120 -0
  47. package/dist/pwa/assets/index-bLTn8zBj.css +1 -0
  48. package/dist/pwa/assets/{web-CkWrlNwc.js → web-BpM3fNCn.js} +1 -1
  49. package/dist/pwa/assets/{web-lx34oBi7.js → web-CF-N8Di6.js} +1 -1
  50. package/dist/pwa/index.html +2 -2
  51. package/dist/pwa/service-worker.js +1 -1
  52. package/dist/rpc-handler.js +35 -24
  53. package/dist/task.js +1 -1
  54. package/dist/transports/http-transport.js +9 -8
  55. package/dist/types.d.ts +11 -6
  56. package/package.json +1 -1
  57. package/palmier-server/README.md +3 -3
  58. package/palmier-server/pwa/src/App.css +175 -28
  59. package/palmier-server/pwa/src/App.tsx +1 -0
  60. package/palmier-server/pwa/src/components/HostMenu.tsx +7 -2
  61. package/palmier-server/pwa/src/components/PullToRefreshIndicator.tsx +46 -0
  62. package/palmier-server/pwa/src/components/RunDetailView.tsx +58 -15
  63. package/palmier-server/pwa/src/components/SessionComposer.tsx +147 -0
  64. package/palmier-server/pwa/src/components/{RunsView.tsx → SessionsView.tsx} +79 -45
  65. package/palmier-server/pwa/src/components/TabBar.tsx +17 -10
  66. package/palmier-server/pwa/src/components/TaskCard.tsx +33 -35
  67. package/palmier-server/pwa/src/components/TaskForm.tsx +275 -349
  68. package/palmier-server/pwa/src/components/TasksView.tsx +172 -0
  69. package/palmier-server/pwa/src/constants.ts +1 -1
  70. package/palmier-server/pwa/src/contexts/HostStoreContext.tsx +16 -8
  71. package/palmier-server/pwa/src/draftGuard.ts +24 -0
  72. package/palmier-server/pwa/src/hooks/usePullToRefresh.ts +102 -0
  73. package/palmier-server/pwa/src/pages/Dashboard.tsx +343 -37
  74. package/palmier-server/pwa/src/types.ts +5 -14
  75. package/palmier-server/spec.md +39 -26
  76. package/src/agents/agent.ts +5 -1
  77. package/src/agents/aider.ts +1 -0
  78. package/src/agents/claude.ts +1 -0
  79. package/src/agents/cline.ts +1 -0
  80. package/src/agents/codex.ts +1 -0
  81. package/src/agents/copilot.ts +1 -0
  82. package/src/agents/cursor.ts +1 -0
  83. package/src/agents/deepagents.ts +1 -0
  84. package/src/agents/droid.ts +1 -0
  85. package/src/agents/gemini.ts +1 -0
  86. package/src/agents/goose.ts +1 -0
  87. package/src/agents/hermes.ts +1 -0
  88. package/src/agents/kimi.ts +1 -0
  89. package/src/agents/kiro.ts +1 -0
  90. package/src/agents/openclaw.ts +2 -2
  91. package/src/agents/opencode.ts +1 -0
  92. package/src/agents/qoder.ts +1 -0
  93. package/src/agents/qwen.ts +1 -0
  94. package/src/commands/pair.ts +2 -2
  95. package/src/mcp-tools.ts +22 -9
  96. package/src/pending-requests.ts +47 -15
  97. package/src/platform/linux.ts +10 -8
  98. package/src/platform/windows.ts +15 -12
  99. package/src/rpc-handler.ts +39 -26
  100. package/src/task.ts +1 -1
  101. package/src/transports/http-transport.ts +9 -8
  102. package/src/types.ts +10 -8
  103. package/test/pairing.test.ts +2 -2
  104. package/dist/pwa/assets/index-B-ByUHPS.css +0 -1
  105. package/dist/pwa/assets/index-Bt8Hhaw3.js +0 -118
  106. package/palmier-server/pwa/src/components/TaskListView.tsx +0 -431
@@ -67,6 +67,34 @@ body {
67
67
  min-height: 100dvh;
68
68
  -webkit-font-smoothing: antialiased;
69
69
  -moz-osx-font-smoothing: grayscale;
70
+ overscroll-behavior-y: contain;
71
+ }
72
+
73
+ .pull-to-refresh {
74
+ position: fixed;
75
+ top: -48px;
76
+ left: 0;
77
+ right: 0;
78
+ height: 48px;
79
+ display: flex;
80
+ align-items: center;
81
+ justify-content: center;
82
+ pointer-events: none;
83
+ z-index: 40;
84
+ transition: opacity 0.15s;
85
+ }
86
+
87
+ .pull-to-refresh-badge {
88
+ width: 40px;
89
+ height: 40px;
90
+ border-radius: 50%;
91
+ background: var(--color-surface);
92
+ border: 1px solid var(--color-border);
93
+ box-shadow: var(--shadow-md);
94
+ display: flex;
95
+ align-items: center;
96
+ justify-content: center;
97
+ color: var(--color-text-secondary);
70
98
  }
71
99
 
72
100
  #root {
@@ -578,26 +606,104 @@ body {
578
606
  }
579
607
  }
580
608
 
581
- /* ===== Task List ===== */
582
- .new-task-input-card {
609
+ /* ===== Session Composer ===== */
610
+ .session-composer {
583
611
  background: var(--color-surface);
584
612
  border-radius: var(--radius-md);
585
613
  border: 1px solid var(--color-border);
586
614
  box-shadow: var(--shadow-sm);
587
- padding: var(--space-md);
588
- margin-bottom: var(--space-sm);
589
- cursor: pointer;
615
+ padding: var(--space-sm);
616
+ margin-bottom: var(--space-md);
617
+ display: flex;
618
+ flex-direction: column;
619
+ gap: var(--space-sm);
590
620
  }
591
621
 
592
- .new-task-input-card:hover {
622
+ .session-composer:focus-within {
593
623
  border-color: var(--color-primary);
624
+ box-shadow: 0 0 0 2px var(--color-input-focus);
594
625
  }
595
626
 
596
- .new-task-placeholder {
597
- font-size: 0.875rem;
627
+ .session-composer-textarea {
628
+ width: 100%;
629
+ border: none;
630
+ resize: vertical;
631
+ outline: none;
632
+ background: transparent;
633
+ color: var(--color-text);
634
+ font-family: var(--font-sans);
635
+ font-size: 0.9375rem;
636
+ line-height: 1.5;
637
+ padding: var(--space-xs) var(--space-sm);
638
+ min-height: 3.5em;
639
+ }
640
+
641
+ .session-composer-textarea::placeholder {
598
642
  color: var(--color-muted);
599
643
  }
600
644
 
645
+ .session-composer-controls {
646
+ display: flex;
647
+ align-items: center;
648
+ gap: var(--space-sm);
649
+ padding-left: var(--space-xs);
650
+ }
651
+
652
+ .session-composer-yolo {
653
+ display: inline-flex;
654
+ align-items: center;
655
+ gap: 4px;
656
+ font-size: 0.8rem;
657
+ color: var(--color-text-secondary);
658
+ cursor: pointer;
659
+ user-select: none;
660
+ }
661
+
662
+ .session-composer-yolo input {
663
+ margin: 0;
664
+ cursor: pointer;
665
+ }
666
+
667
+ .session-composer-controls .chat-send-btn {
668
+ margin-left: auto;
669
+ }
670
+
671
+ /* ===== Task List ===== */
672
+ .fab {
673
+ position: fixed;
674
+ right: var(--space-lg);
675
+ bottom: calc(var(--space-lg) + env(safe-area-inset-bottom, 0px));
676
+ width: 56px;
677
+ height: 56px;
678
+ border-radius: 50%;
679
+ border: none;
680
+ background: var(--color-primary);
681
+ color: #fff;
682
+ display: inline-flex;
683
+ align-items: center;
684
+ justify-content: center;
685
+ cursor: pointer;
686
+ box-shadow: var(--shadow-lg);
687
+ transition: background var(--transition-base), transform var(--transition-fast), box-shadow var(--transition-base);
688
+ z-index: 50;
689
+ -webkit-tap-highlight-color: transparent;
690
+ }
691
+
692
+ .fab:hover {
693
+ background: var(--color-primary-hover);
694
+ box-shadow: var(--shadow-xl);
695
+ transform: translateY(-1px);
696
+ }
697
+
698
+ .fab:active {
699
+ transform: translateY(0);
700
+ }
701
+
702
+ .fab:focus-visible {
703
+ outline: 2px solid var(--color-primary);
704
+ outline-offset: 3px;
705
+ }
706
+
601
707
  .section-label {
602
708
  font-size: 0.6875rem;
603
709
  font-weight: 600;
@@ -611,7 +717,6 @@ body {
611
717
  display: flex;
612
718
  align-items: center;
613
719
  gap: var(--space-xs);
614
- margin-left: auto;
615
720
  }
616
721
 
617
722
  .agent-picker-section-inline .agent-picker-label {
@@ -1096,15 +1201,57 @@ body {
1096
1201
  font-weight: 600;
1097
1202
  }
1098
1203
 
1099
- .triggers-section-body {
1204
+ .granted-permissions-row {
1205
+ flex-basis: 100%;
1206
+ }
1207
+
1208
+ .granted-permissions-row .btn-link {
1209
+ padding: 0;
1210
+ }
1211
+
1212
+ .schedule-section {
1100
1213
  display: flex;
1101
1214
  flex-direction: column;
1102
- gap: 2px;
1215
+ gap: var(--space-sm);
1103
1216
  }
1104
1217
 
1105
- .triggers-section-body.disabled {
1106
- opacity: 0.4;
1107
- pointer-events: none;
1218
+ .schedule-section-title {
1219
+ font-size: 0.75rem;
1220
+ font-weight: 600;
1221
+ text-transform: uppercase;
1222
+ letter-spacing: 0.05em;
1223
+ color: var(--color-muted);
1224
+ margin: 0;
1225
+ }
1226
+
1227
+ .schedule-reactive {
1228
+ display: flex;
1229
+ flex-direction: column;
1230
+ gap: var(--space-xs);
1231
+ }
1232
+
1233
+ .yolo-inline {
1234
+ display: inline-flex;
1235
+ align-items: center;
1236
+ gap: 4px;
1237
+ font-size: 0.8rem;
1238
+ color: var(--color-text-secondary);
1239
+ cursor: pointer;
1240
+ user-select: none;
1241
+ white-space: nowrap;
1242
+ }
1243
+
1244
+ .yolo-inline input {
1245
+ margin: 0;
1246
+ cursor: pointer;
1247
+ }
1248
+
1249
+ .yolo-warning {
1250
+ flex-basis: 100%;
1251
+ margin: 0;
1252
+ font-size: 0.75rem;
1253
+ color: var(--color-text-secondary);
1254
+ line-height: 1.4;
1108
1255
  }
1109
1256
 
1110
1257
  .trigger-row-card {
@@ -1152,7 +1299,7 @@ body {
1152
1299
  width: auto;
1153
1300
  }
1154
1301
 
1155
- .triggers-section-body > .form-select,
1302
+ .schedule-section > .form-select,
1156
1303
  .trigger-row-card .form-select,
1157
1304
  .trigger-row-card .form-input {
1158
1305
  margin-bottom: 0;
@@ -1163,7 +1310,7 @@ body {
1163
1310
  min-width: 0;
1164
1311
  }
1165
1312
 
1166
- .triggers-section-body > .form-select {
1313
+ .schedule-section > .form-select {
1167
1314
  width: 100%;
1168
1315
  }
1169
1316
 
@@ -1855,9 +2002,9 @@ body {
1855
2002
  border-bottom-color: var(--color-primary);
1856
2003
  }
1857
2004
 
1858
- /* ===== Runs view ===== */
2005
+ /* ===== Sessions view ===== */
1859
2006
 
1860
- .runs-view {
2007
+ .sessions-view {
1861
2008
  flex: 1;
1862
2009
  display: flex;
1863
2010
  flex-direction: column;
@@ -1865,7 +2012,7 @@ body {
1865
2012
  justify-content: center;
1866
2013
  }
1867
2014
 
1868
- .runs-card {
2015
+ .sessions-card {
1869
2016
  background: var(--color-surface);
1870
2017
  border-radius: var(--radius-md);
1871
2018
  border: 1px solid var(--color-border);
@@ -1879,12 +2026,12 @@ body {
1879
2026
  -webkit-tap-highlight-color: transparent;
1880
2027
  }
1881
2028
 
1882
- .runs-card:hover {
2029
+ .sessions-card:hover {
1883
2030
  box-shadow: var(--shadow-md);
1884
2031
  border-color: #CBD5E1;
1885
2032
  }
1886
2033
 
1887
- .runs-card-body {
2034
+ .sessions-card-body {
1888
2035
  flex: 1;
1889
2036
  min-width: 0;
1890
2037
  display: flex;
@@ -1892,7 +2039,7 @@ body {
1892
2039
  gap: var(--space-xs);
1893
2040
  }
1894
2041
 
1895
- .runs-card-name {
2042
+ .sessions-card-name {
1896
2043
  font-size: 0.9375rem;
1897
2044
  font-weight: 600;
1898
2045
  letter-spacing: -0.01em;
@@ -1902,7 +2049,7 @@ body {
1902
2049
  text-overflow: ellipsis;
1903
2050
  }
1904
2051
 
1905
- .runs-card-meta {
2052
+ .sessions-card-meta {
1906
2053
  display: flex;
1907
2054
  flex-wrap: wrap;
1908
2055
  gap: var(--space-sm);
@@ -1910,7 +2057,7 @@ body {
1910
2057
  color: var(--color-text-secondary);
1911
2058
  }
1912
2059
 
1913
- .runs-card-chevron {
2060
+ .sessions-card-chevron {
1914
2061
  flex-shrink: 0;
1915
2062
  align-self: center;
1916
2063
  color: var(--color-text-secondary);
@@ -1920,11 +2067,11 @@ body {
1920
2067
  transition: opacity var(--transition-base);
1921
2068
  }
1922
2069
 
1923
- .runs-card:hover .runs-card-chevron {
2070
+ .sessions-card:hover .sessions-card-chevron {
1924
2071
  opacity: 0.8;
1925
2072
  }
1926
2073
 
1927
- .runs-filter-chip {
2074
+ .sessions-filter-chip {
1928
2075
  display: inline-flex;
1929
2076
  align-items: center;
1930
2077
  gap: var(--space-xs);
@@ -1936,7 +2083,7 @@ body {
1936
2083
  border-radius: 999px;
1937
2084
  }
1938
2085
 
1939
- .runs-filter-chip button {
2086
+ .sessions-filter-chip button {
1940
2087
  display: inline-flex;
1941
2088
  align-items: center;
1942
2089
  justify-content: center;
@@ -1949,7 +2096,7 @@ body {
1949
2096
  line-height: 1;
1950
2097
  }
1951
2098
 
1952
- .runs-filter-chip button:hover {
2099
+ .sessions-filter-chip button:hover {
1953
2100
  color: var(--color-text);
1954
2101
  }
1955
2102
 
@@ -10,6 +10,7 @@ export default function App() {
10
10
  <HostConnectionProvider>
11
11
  <Routes>
12
12
  <Route path="/" element={<Dashboard />} />
13
+ <Route path="/tasks" element={<Dashboard />} />
13
14
  <Route path="/runs" element={<Dashboard />} />
14
15
  <Route path="/runs/:taskId" element={<Dashboard />} />
15
16
  <Route path="/runs/:taskId/:runId" element={<Dashboard />} />
@@ -98,6 +98,7 @@ const FullScreenIntent = Capacitor.isNativePlatform()
98
98
  : null;
99
99
  import { useHostStore } from "../contexts/HostStoreContext";
100
100
  import { useMediaQuery } from "../hooks/useMediaQuery";
101
+ import { confirmLeaveDraft } from "../draftGuard";
101
102
 
102
103
  /** LAN mode: PWA is served by palmier serve (marker injected into HTML). */
103
104
  const isLanMode = !!(window as any).__PALMIER_SERVE__;
@@ -500,7 +501,11 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
500
501
  className={`host-picker-item ${isActive ? "host-picker-item-active" : ""}`}
501
502
  onClick={() => {
502
503
  if (isRenaming) return;
503
- if (!isActive) { setActiveHostId(host.hostId); if (!isDesktop) close(); }
504
+ if (!isActive) {
505
+ if (!confirmLeaveDraft()) return;
506
+ setActiveHostId(host.hostId);
507
+ if (!isDesktop) close();
508
+ }
504
509
  }}
505
510
  role="option"
506
511
  aria-selected={isActive}
@@ -580,7 +585,7 @@ export default function HostMenu({ daemonVersion, capabilityTokens, activeClient
580
585
  <div className="drawer-section">
581
586
  <button
582
587
  className="btn btn-primary btn-full"
583
- onClick={() => { navigate("/pair"); if (!isDesktop) close(); }}
588
+ onClick={() => { if (!confirmLeaveDraft()) return; navigate("/pair"); if (!isDesktop) close(); }}
584
589
  >
585
590
  Pair New Host
586
591
  </button>
@@ -0,0 +1,46 @@
1
+ interface Props {
2
+ pullDistance: number;
3
+ refreshing: boolean;
4
+ threshold: number;
5
+ }
6
+
7
+ /**
8
+ * Fixed-position indicator parked just above the viewport that slides down as
9
+ * the user pulls. Opacity ramps up quickly so the badge peeks in immediately;
10
+ * the arrow flips upward once the pull has crossed the release threshold.
11
+ */
12
+ export default function PullToRefreshIndicator({ pullDistance, refreshing, threshold }: Props) {
13
+ const visible = pullDistance > 0 || refreshing;
14
+ const ready = pullDistance >= threshold;
15
+ return (
16
+ <div
17
+ className="pull-to-refresh"
18
+ style={{
19
+ transform: `translateY(${pullDistance}px)`,
20
+ opacity: visible ? Math.min(1, pullDistance / 30) : 0,
21
+ }}
22
+ aria-hidden={!visible}
23
+ >
24
+ <div className="pull-to-refresh-badge">
25
+ {refreshing ? (
26
+ <div className="spinner" />
27
+ ) : (
28
+ <svg
29
+ width="18"
30
+ height="18"
31
+ viewBox="0 0 24 24"
32
+ fill="none"
33
+ stroke="currentColor"
34
+ strokeWidth="2.25"
35
+ strokeLinecap="round"
36
+ strokeLinejoin="round"
37
+ style={{ transform: ready ? "rotate(180deg)" : "rotate(0deg)", transition: "transform 0.15s" }}
38
+ >
39
+ <line x1="12" y1="5" x2="12" y2="19" />
40
+ <polyline points="19 12 12 19 5 12" />
41
+ </svg>
42
+ )}
43
+ </div>
44
+ </div>
45
+ );
46
+ }
@@ -30,8 +30,33 @@ export default function RunDetailView({ connected, hostId, request, subscribeEve
30
30
  const [followupText, setFollowupText] = useState("");
31
31
  const [sendingFollowup, setSendingFollowup] = useState(false);
32
32
  const threadRef = useRef<HTMLDivElement>(null);
33
+ const followupInputRef = useRef<HTMLInputElement>(null);
34
+ // Tracks which runId we've already scrolled-and-focused for, so new messages
35
+ // during the session don't steal focus back to the input.
36
+ const initialFocusForRunId = useRef<string | null>(null);
37
+
38
+ // Resolve the "latest" sentinel to an actual run_id. `undefined` = still
39
+ // resolving, `null` = task has no runs yet (empty state), otherwise the id
40
+ // to load.
41
+ const [resolvedRunId, setResolvedRunId] = useState<string | null | undefined>(
42
+ runId === "latest" ? undefined : runId,
43
+ );
44
+ const isLatestEmpty = runId === "latest" && resolvedRunId === null;
45
+
46
+ useEffect(() => {
47
+ if (runId !== "latest") {
48
+ setResolvedRunId(runId);
49
+ return;
50
+ }
51
+ if (!connected) return;
52
+ setResolvedRunId(undefined);
53
+ request<{ entries?: Array<{ run_id: string }> }>("taskrun.list", { task_id: taskId, limit: 1 })
54
+ .then((result) => setResolvedRunId(result.entries?.[0]?.run_id ?? null))
55
+ .catch(() => setResolvedRunId(null));
56
+ }, [runId, taskId, connected, request]);
33
57
 
34
58
  async function fetchData() {
59
+ if (!resolvedRunId) return;
35
60
  try {
36
61
  const result = await request<{
37
62
  messages?: ConversationMessage[];
@@ -40,7 +65,7 @@ export default function RunDetailView({ connected, hostId, request, subscribeEve
40
65
  running_state?: string;
41
66
  agent?: string;
42
67
  error?: string;
43
- }>("task.result", { id: taskId, run_id: runId });
68
+ }>("task.result", { id: taskId, run_id: resolvedRunId });
44
69
 
45
70
  if (result.error) {
46
71
  console.error("No result:", result.error);
@@ -59,9 +84,10 @@ export default function RunDetailView({ connected, hostId, request, subscribeEve
59
84
  }
60
85
 
61
86
  async function openReport(file: string) {
87
+ if (!resolvedRunId) return;
62
88
  try {
63
89
  const result = await request<{ reports: Array<{ file: string; content?: string; data_url?: string }> }>(
64
- "task.reports", { id: taskId, run_id: runId, report_files: [file] },
90
+ "task.reports", { id: taskId, run_id: resolvedRunId, report_files: [file] },
65
91
  );
66
92
  const report = result.reports?.[0];
67
93
  if (report?.data_url) {
@@ -74,30 +100,28 @@ export default function RunDetailView({ connected, hostId, request, subscribeEve
74
100
  }
75
101
  }
76
102
 
77
- // Initial load
103
+ // Initial load once resolvedRunId becomes a concrete id.
78
104
  useEffect(() => {
79
- if (connected) {
80
- setLoading(true);
81
- fetchData();
82
- }
83
- }, [connected, taskId, runId]);
105
+ if (!connected || !resolvedRunId) return;
106
+ setLoading(true);
107
+ fetchData();
108
+ }, [connected, taskId, resolvedRunId]);
84
109
 
85
110
  // Live-update when the viewed task's state changes
86
111
  useEffect(() => {
87
- if (!connected || !hostId) return;
112
+ if (!connected || !hostId || !resolvedRunId) return;
88
113
  const unsubscribe = subscribeEvents(hostId, async (msg) => {
89
114
  try {
90
115
  const parsed = JSON.parse(new TextDecoder().decode(msg.data)) as { event_type?: string; run_id?: string };
91
116
  if (parsed.event_type !== "running-state" && parsed.event_type !== "result-updated") return;
92
117
  const eventTaskId = msg.subject.split(".").pop();
93
118
  if (eventTaskId !== taskId) return;
94
- // result-updated events are scoped to a specific result file
95
- if (parsed.event_type === "result-updated" && parsed.run_id && parsed.run_id !== runId) return;
119
+ if (parsed.event_type === "result-updated" && parsed.run_id && parsed.run_id !== resolvedRunId) return;
96
120
  fetchData();
97
121
  } catch { /* skip */ }
98
122
  });
99
123
  return unsubscribe;
100
- }, [connected, hostId, taskId, subscribeEvents, request]);
124
+ }, [connected, hostId, taskId, resolvedRunId, subscribeEvents, request]);
101
125
 
102
126
  // Auto-scroll to bottom when messages change
103
127
  useEffect(() => {
@@ -106,6 +130,19 @@ export default function RunDetailView({ connected, hostId, request, subscribeEve
106
130
  }
107
131
  }, [messages]);
108
132
 
133
+ // On first load of a run, scroll the window to the bottom so the follow-up
134
+ // input is visible, and focus the input if it's rendered (agent not running).
135
+ useEffect(() => {
136
+ if (loading || isLatestEmpty || !resolvedRunId) return;
137
+ if (initialFocusForRunId.current === resolvedRunId) return;
138
+ initialFocusForRunId.current = resolvedRunId;
139
+ requestAnimationFrame(() => {
140
+ if (threadRef.current) threadRef.current.scrollTop = threadRef.current.scrollHeight;
141
+ window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "auto" });
142
+ if (!isAgentGenerating) followupInputRef.current?.focus();
143
+ });
144
+ }, [loading, isLatestEmpty, resolvedRunId, isAgentGenerating]);
145
+
109
146
  function typeLabel(type?: string): string | undefined {
110
147
  if (type === "input") return "User Input";
111
148
  if (type === "permission") return "Permission";
@@ -133,7 +170,12 @@ export default function RunDetailView({ connected, hostId, request, subscribeEve
133
170
  Back
134
171
  </button>
135
172
 
136
- {loading ? (
173
+ {isLatestEmpty ? (
174
+ <div className="empty-state">
175
+ <p className="empty-state-text">No runs yet</p>
176
+ <p className="empty-state-hint">This task hasn't been executed yet. Run it from the task menu or wait for its next trigger.</p>
177
+ </div>
178
+ ) : loading ? (
137
179
  <div style={{ display: "flex", flexDirection: "column", gap: "var(--space-sm)", padding: "var(--space-sm) 0" }}>
138
180
  <div className="skeleton-line" style={{ width: "40%" }} />
139
181
  <div className="skeleton-line" style={{ width: "55%" }} />
@@ -223,7 +265,7 @@ export default function RunDetailView({ connected, hostId, request, subscribeEve
223
265
  onClick={async () => {
224
266
  setAborting(true);
225
267
  try {
226
- await request("task.stop_followup", { id: taskId, run_id: runId });
268
+ await request("task.stop_followup", { id: taskId, run_id: resolvedRunId });
227
269
  } catch (err) {
228
270
  console.error("Stop failed:", err);
229
271
  } finally {
@@ -243,7 +285,7 @@ export default function RunDetailView({ connected, hostId, request, subscribeEve
243
285
  if (!msg || sendingFollowup) return;
244
286
  setSendingFollowup(true);
245
287
  try {
246
- await request("task.followup", { id: taskId, run_id: runId, message: msg });
288
+ await request("task.followup", { id: taskId, run_id: resolvedRunId, message: msg });
247
289
  setFollowupText("");
248
290
  } catch (err) {
249
291
  console.error("Follow-up failed:", err);
@@ -253,6 +295,7 @@ export default function RunDetailView({ connected, hostId, request, subscribeEve
253
295
  }}
254
296
  >
255
297
  <input
298
+ ref={followupInputRef}
256
299
  className="chat-input"
257
300
  type="text"
258
301
  placeholder="Follow-up message"