Add basic cron scheduler to goose-server (#2621)
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
// /Users/mnovich/Development/goose-1.0/ui/desktop/src/components/icons/TrashIcon.tsx
|
||||
import React from 'react';
|
||||
|
||||
interface IconProps extends React.SVGProps<globalThis.SVGSVGElement> {}
|
||||
|
||||
export const TrashIcon: React.FC<IconProps> = (props) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
{...props} // Allows passing className, w-5, h-5, etc.
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -125,17 +125,14 @@ export default function MoreMenu({
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
// Handler for system theme changes
|
||||
const handleThemeChange = (e: { matches: boolean }) => {
|
||||
if (themeMode === 'system') {
|
||||
setDarkMode(e.matches);
|
||||
}
|
||||
};
|
||||
|
||||
// Add listener for system theme changes
|
||||
mediaQuery.addEventListener('change', handleThemeChange);
|
||||
|
||||
// Initial setup
|
||||
if (themeMode === 'system') {
|
||||
setDarkMode(mediaQuery.matches);
|
||||
localStorage.setItem('use_system_theme', 'true');
|
||||
@@ -145,7 +142,6 @@ export default function MoreMenu({
|
||||
localStorage.setItem('theme', themeMode);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
return () => mediaQuery.removeEventListener('change', handleThemeChange);
|
||||
}, [themeMode]);
|
||||
|
||||
@@ -221,6 +217,16 @@ export default function MoreMenu({
|
||||
Session history
|
||||
</MenuButton>
|
||||
|
||||
{process.env.ALPHA && (
|
||||
<MenuButton
|
||||
onClick={() => setView('schedules')}
|
||||
subtitle="Manage scheduled runs"
|
||||
icon={<Time className="w-4 h-4" />}
|
||||
>
|
||||
Scheduler
|
||||
</MenuButton>
|
||||
)}
|
||||
|
||||
<MenuButton
|
||||
onClick={() => setIsGoosehintsModalOpen(true)}
|
||||
subtitle="Customize instructions"
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
import React, { useState, useEffect, FormEvent } from 'react';
|
||||
import { Card } from '../ui/card';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Select } from '../ui/select';
|
||||
import cronstrue from 'cronstrue';
|
||||
|
||||
type FrequencyValue = 'once' | 'hourly' | 'daily' | 'weekly' | 'monthly';
|
||||
|
||||
interface FrequencyOption {
|
||||
value: FrequencyValue;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface NewSchedulePayload {
|
||||
id: string;
|
||||
recipe_source: string;
|
||||
cron: string;
|
||||
}
|
||||
|
||||
interface CreateScheduleModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (payload: NewSchedulePayload) => Promise<void>;
|
||||
isLoadingExternally: boolean;
|
||||
apiErrorExternally: string | null;
|
||||
}
|
||||
|
||||
const frequencies: FrequencyOption[] = [
|
||||
{ value: 'once', label: 'Once' },
|
||||
{ value: 'hourly', label: 'Hourly' },
|
||||
{ value: 'daily', label: 'Daily' },
|
||||
{ value: 'weekly', label: 'Weekly' },
|
||||
{ value: 'monthly', label: 'Monthly' },
|
||||
];
|
||||
|
||||
const daysOfWeekOptions: { value: string; label: string }[] = [
|
||||
{ value: '1', label: 'Mon' },
|
||||
{ value: '2', label: 'Tue' },
|
||||
{ value: '3', label: 'Wed' },
|
||||
{ value: '4', label: 'Thu' },
|
||||
{ value: '5', label: 'Fri' },
|
||||
{ value: '6', label: 'Sat' },
|
||||
{ value: '0', label: 'Sun' },
|
||||
];
|
||||
|
||||
const modalLabelClassName = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1';
|
||||
const cronPreviewTextColor = 'text-xs text-gray-500 dark:text-gray-400 mt-1';
|
||||
const cronPreviewSpecialNoteColor = 'text-xs text-yellow-600 dark:text-yellow-500 mt-1';
|
||||
const checkboxLabelClassName = 'flex items-center text-sm text-textStandard dark:text-gray-300';
|
||||
const checkboxInputClassName =
|
||||
'h-4 w-4 text-indigo-600 border-gray-300 dark:border-gray-600 rounded focus:ring-indigo-500 mr-2';
|
||||
|
||||
export const CreateScheduleModal: React.FC<CreateScheduleModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
isLoadingExternally,
|
||||
apiErrorExternally,
|
||||
}) => {
|
||||
const [scheduleId, setScheduleId] = useState<string>('');
|
||||
const [recipeSourcePath, setRecipeSourcePath] = useState<string>('');
|
||||
const [frequency, setFrequency] = useState<FrequencyValue>('daily');
|
||||
const [selectedDate, setSelectedDate] = useState<string>(
|
||||
() => new Date().toISOString().split('T')[0]
|
||||
);
|
||||
const [selectedTime, setSelectedTime] = useState<string>('09:00');
|
||||
const [selectedMinute, setSelectedMinute] = useState<string>('0');
|
||||
const [selectedDaysOfWeek, setSelectedDaysOfWeek] = useState<Set<string>>(new Set(['1']));
|
||||
const [selectedDayOfMonth, setSelectedDayOfMonth] = useState<string>('1');
|
||||
const [derivedCronExpression, setDerivedCronExpression] = useState<string>('');
|
||||
const [readableCronExpression, setReadableCronExpression] = useState<string>('');
|
||||
const [internalValidationError, setInternalValidationError] = useState<string | null>(null);
|
||||
|
||||
const resetForm = () => {
|
||||
setScheduleId('');
|
||||
setRecipeSourcePath('');
|
||||
setFrequency('daily');
|
||||
setSelectedDate(new Date().toISOString().split('T')[0]);
|
||||
setSelectedTime('09:00');
|
||||
setSelectedMinute('0');
|
||||
setSelectedDaysOfWeek(new Set(['1']));
|
||||
setSelectedDayOfMonth('1');
|
||||
setInternalValidationError(null);
|
||||
setReadableCronExpression('');
|
||||
};
|
||||
|
||||
const handleBrowseFile = async () => {
|
||||
const filePath = await window.electron.selectFileOrDirectory();
|
||||
if (filePath) {
|
||||
if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) {
|
||||
setRecipeSourcePath(filePath);
|
||||
setInternalValidationError(null);
|
||||
} else {
|
||||
setInternalValidationError('Invalid file type: Please select a YAML file (.yaml or .yml)');
|
||||
console.warn('Invalid file type: Please select a YAML file (.yaml or .yml)');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const generateCronExpression = (): string => {
|
||||
const timeParts = selectedTime.split(':');
|
||||
const minutePart = timeParts.length > 1 ? String(parseInt(timeParts[1], 10)) : '0';
|
||||
const hourPart = timeParts.length > 0 ? String(parseInt(timeParts[0], 10)) : '0';
|
||||
if (isNaN(parseInt(minutePart)) || isNaN(parseInt(hourPart))) {
|
||||
return 'Invalid time format.';
|
||||
}
|
||||
const secondsPart = '0';
|
||||
switch (frequency) {
|
||||
case 'once':
|
||||
if (selectedDate && selectedTime) {
|
||||
try {
|
||||
const dateObj = new Date(`${selectedDate}T${selectedTime}`);
|
||||
if (isNaN(dateObj.getTime())) return "Invalid date/time for 'once'.";
|
||||
return `${secondsPart} ${dateObj.getMinutes()} ${dateObj.getHours()} ${dateObj.getDate()} ${
|
||||
dateObj.getMonth() + 1
|
||||
} *`;
|
||||
} catch (e) {
|
||||
return "Error parsing date/time for 'once'.";
|
||||
}
|
||||
}
|
||||
return 'Date and Time are required for "Once" frequency.';
|
||||
case 'hourly': {
|
||||
const sMinute = parseInt(selectedMinute, 10);
|
||||
if (isNaN(sMinute) || sMinute < 0 || sMinute > 59) {
|
||||
return 'Invalid minute (0-59) for hourly frequency.';
|
||||
}
|
||||
return `${secondsPart} ${sMinute} * * * *`;
|
||||
}
|
||||
case 'daily':
|
||||
return `${secondsPart} ${minutePart} ${hourPart} * * *`;
|
||||
case 'weekly': {
|
||||
if (selectedDaysOfWeek.size === 0) {
|
||||
return 'Select at least one day for weekly frequency.';
|
||||
}
|
||||
const days = Array.from(selectedDaysOfWeek)
|
||||
.sort((a, b) => parseInt(a) - parseInt(b))
|
||||
.join(',');
|
||||
return `${secondsPart} ${minutePart} ${hourPart} * * ${days}`;
|
||||
}
|
||||
case 'monthly': {
|
||||
const sDayOfMonth = parseInt(selectedDayOfMonth, 10);
|
||||
if (isNaN(sDayOfMonth) || sDayOfMonth < 1 || sDayOfMonth > 31) {
|
||||
return 'Invalid day of month (1-31) for monthly frequency.';
|
||||
}
|
||||
return `${secondsPart} ${minutePart} ${hourPart} ${sDayOfMonth} * *`;
|
||||
}
|
||||
default:
|
||||
return 'Invalid frequency selected.';
|
||||
}
|
||||
};
|
||||
const cron = generateCronExpression();
|
||||
setDerivedCronExpression(cron);
|
||||
try {
|
||||
if (
|
||||
cron.includes('Invalid') ||
|
||||
cron.includes('required') ||
|
||||
cron.includes('Error') ||
|
||||
cron.includes('Select at least one')
|
||||
) {
|
||||
setReadableCronExpression('Invalid cron details provided.');
|
||||
} else {
|
||||
setReadableCronExpression(cronstrue.toString(cron));
|
||||
}
|
||||
} catch (e) {
|
||||
setReadableCronExpression('Could not parse cron string.');
|
||||
}
|
||||
}, [
|
||||
frequency,
|
||||
selectedDate,
|
||||
selectedTime,
|
||||
selectedMinute,
|
||||
selectedDaysOfWeek,
|
||||
selectedDayOfMonth,
|
||||
]);
|
||||
|
||||
const handleDayOfWeekChange = (dayValue: string) => {
|
||||
setSelectedDaysOfWeek((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(dayValue)) {
|
||||
newSet.delete(dayValue);
|
||||
} else {
|
||||
newSet.add(dayValue);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const handleLocalSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setInternalValidationError(null);
|
||||
|
||||
if (!scheduleId.trim()) {
|
||||
setInternalValidationError('Schedule ID is required.');
|
||||
return;
|
||||
}
|
||||
if (!recipeSourcePath) {
|
||||
setInternalValidationError('Recipe source file is required.');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!derivedCronExpression ||
|
||||
derivedCronExpression.includes('Invalid') ||
|
||||
derivedCronExpression.includes('required') ||
|
||||
derivedCronExpression.includes('Error') ||
|
||||
derivedCronExpression.includes('Select at least one')
|
||||
) {
|
||||
setInternalValidationError(`Invalid cron expression: ${derivedCronExpression}`);
|
||||
return;
|
||||
}
|
||||
if (frequency === 'weekly' && selectedDaysOfWeek.size === 0) {
|
||||
setInternalValidationError('For weekly frequency, select at least one day.');
|
||||
return;
|
||||
}
|
||||
|
||||
const newSchedulePayload: NewSchedulePayload = {
|
||||
id: scheduleId.trim(),
|
||||
recipe_source: recipeSourcePath,
|
||||
cron: derivedCronExpression,
|
||||
};
|
||||
|
||||
await onSubmit(newSchedulePayload);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
resetForm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/20 backdrop-blur-sm z-40 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md bg-bgApp shadow-xl rounded-lg z-50 flex flex-col max-h-[90vh] overflow-hidden">
|
||||
<div className="px-6 pt-6 pb-4 flex-shrink-0">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Create New Schedule
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<form
|
||||
id="new-schedule-form"
|
||||
onSubmit={handleLocalSubmit}
|
||||
className="px-6 py-4 space-y-4 flex-grow overflow-y-auto"
|
||||
>
|
||||
{apiErrorExternally && (
|
||||
<p className="text-red-500 text-sm mb-3 p-2 bg-red-100 dark:bg-red-900/30 rounded-md border border-red-500/50">
|
||||
{apiErrorExternally}
|
||||
</p>
|
||||
)}
|
||||
{internalValidationError && (
|
||||
<p className="text-red-500 text-sm mb-3 p-2 bg-red-100 dark:bg-red-900/30 rounded-md border border-red-500/50">
|
||||
{internalValidationError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="scheduleId-modal" className={modalLabelClassName}>
|
||||
Schedule ID:
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
id="scheduleId-modal"
|
||||
value={scheduleId}
|
||||
onChange={(e) => setScheduleId(e.target.value)}
|
||||
placeholder="e.g., daily-summary-job"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={modalLabelClassName}>Recipe Source (YAML File):</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleBrowseFile}
|
||||
className="w-full justify-center"
|
||||
>
|
||||
Browse...
|
||||
</Button>
|
||||
{recipeSourcePath && (
|
||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400 italic">
|
||||
Selected: {recipeSourcePath}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="frequency-modal" className={modalLabelClassName}>
|
||||
Frequency:
|
||||
</label>
|
||||
<Select
|
||||
instanceId="frequency-select-modal"
|
||||
options={frequencies}
|
||||
value={frequencies.find((f) => f.value === frequency)}
|
||||
onChange={(selectedOption: FrequencyOption | null) => {
|
||||
if (selectedOption) setFrequency(selectedOption.value);
|
||||
}}
|
||||
placeholder="Select frequency..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{frequency === 'once' && (
|
||||
<>
|
||||
<div>
|
||||
<label htmlFor="onceDate-modal" className={modalLabelClassName}>
|
||||
Date:
|
||||
</label>
|
||||
<Input
|
||||
type="date"
|
||||
id="onceDate-modal"
|
||||
value={selectedDate}
|
||||
onChange={(e) => setSelectedDate(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="onceTime-modal" className={modalLabelClassName}>
|
||||
Time:
|
||||
</label>
|
||||
<Input
|
||||
type="time"
|
||||
id="onceTime-modal"
|
||||
value={selectedTime}
|
||||
onChange={(e) => setSelectedTime(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{frequency === 'hourly' && (
|
||||
<div>
|
||||
<label htmlFor="hourlyMinute-modal" className={modalLabelClassName}>
|
||||
Minute of the hour (0-59):
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
id="hourlyMinute-modal"
|
||||
min="0"
|
||||
max="59"
|
||||
value={selectedMinute}
|
||||
onChange={(e) => setSelectedMinute(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(frequency === 'daily' || frequency === 'weekly' || frequency === 'monthly') && (
|
||||
<div>
|
||||
<label htmlFor="commonTime-modal" className={modalLabelClassName}>
|
||||
Time:
|
||||
</label>
|
||||
<Input
|
||||
type="time"
|
||||
id="commonTime-modal"
|
||||
value={selectedTime}
|
||||
onChange={(e) => setSelectedTime(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{frequency === 'weekly' && (
|
||||
<div>
|
||||
<label className={modalLabelClassName}>Days of Week:</label>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2 mt-1">
|
||||
{daysOfWeekOptions.map((day) => (
|
||||
<label key={day.value} className={checkboxLabelClassName}>
|
||||
<input
|
||||
type="checkbox"
|
||||
value={day.value}
|
||||
checked={selectedDaysOfWeek.has(day.value)}
|
||||
onChange={() => handleDayOfWeekChange(day.value)}
|
||||
className={checkboxInputClassName}
|
||||
/>
|
||||
{day.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{frequency === 'monthly' && (
|
||||
<div>
|
||||
<label htmlFor="monthlyDay-modal" className={modalLabelClassName}>
|
||||
Day of Month (1-31):
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
id="monthlyDay-modal"
|
||||
min="1"
|
||||
max="31"
|
||||
value={selectedDayOfMonth}
|
||||
onChange={(e) => setSelectedDayOfMonth(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 p-3 bg-gray-100 dark:bg-gray-700/50 rounded-md border border-gray-200 dark:border-gray-600">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Generated Cron:{' '}
|
||||
<code className="text-xs bg-gray-200 dark:bg-gray-600 p-1 rounded">
|
||||
{derivedCronExpression}
|
||||
</code>
|
||||
</p>
|
||||
<p className={`${cronPreviewTextColor} mt-2`}>
|
||||
<b>Human Readable:</b> {readableCronExpression}
|
||||
</p>
|
||||
<p className={cronPreviewTextColor}>Syntax: S M H D M DoW. (S=0, DoW: 0/7=Sun)</p>
|
||||
{frequency === 'once' && (
|
||||
<p className={cronPreviewSpecialNoteColor}>
|
||||
Note: "Once" schedules recur annually. True one-time tasks may need backend deletion
|
||||
after execution.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-[8px] ml-[-24px] mr-[-24px] pt-[16px]">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={handleClose}
|
||||
disabled={isLoadingExternally}
|
||||
className="w-full h-[60px] rounded-none border-t dark:border-gray-600 text-gray-400 hover:bg-gray-50 dark:border-gray-600 text-lg font-regular"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="new-schedule-form"
|
||||
variant="default"
|
||||
disabled={isLoadingExternally}
|
||||
className="w-full h-[60px] rounded-none border-t dark:border-gray-600 text-lg hover:bg-gray-50 hover:dark:text-black dark:text-white dark:border-gray-600 font-regular"
|
||||
>
|
||||
{isLoadingExternally ? 'Creating...' : 'Create Schedule'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,260 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Button } from '../ui/button';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import BackButton from '../ui/BackButton';
|
||||
import { Card } from '../ui/card';
|
||||
import MoreMenuLayout from '../more_menu/MoreMenuLayout';
|
||||
import { fetchSessionDetails, SessionDetails } from '../../sessions';
|
||||
import { getScheduleSessions, runScheduleNow } from '../../schedule';
|
||||
import SessionHistoryView from '../sessions/SessionHistoryView';
|
||||
import { toastError, toastSuccess } from '../../toasts';
|
||||
|
||||
interface ScheduleSessionMeta {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
workingDir?: string;
|
||||
scheduleId?: string | null;
|
||||
messageCount?: number;
|
||||
totalTokens?: number | null;
|
||||
inputTokens?: number | null;
|
||||
outputTokens?: number | null;
|
||||
accumulatedTotalTokens?: number | null;
|
||||
accumulatedInputTokens?: number | null;
|
||||
accumulatedOutputTokens?: number | null;
|
||||
}
|
||||
|
||||
interface ScheduleDetailViewProps {
|
||||
scheduleId: string | null;
|
||||
onNavigateBack: () => void;
|
||||
}
|
||||
|
||||
const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onNavigateBack }) => {
|
||||
const [sessions, setSessions] = useState<ScheduleSessionMeta[]>([]);
|
||||
const [isLoadingSessions, setIsLoadingSessions] = useState(false);
|
||||
const [sessionsError, setSessionsError] = useState<string | null>(null);
|
||||
const [runNowLoading, setRunNowLoading] = useState(false);
|
||||
|
||||
const [selectedSessionDetails, setSelectedSessionDetails] = useState<SessionDetails | null>(null);
|
||||
const [isLoadingSessionDetails, setIsLoadingSessionDetails] = useState(false);
|
||||
const [sessionDetailsError, setSessionDetailsError] = useState<string | null>(null);
|
||||
|
||||
const fetchScheduleSessions = useCallback(async (sId: string) => {
|
||||
if (!sId) return;
|
||||
setIsLoadingSessions(true);
|
||||
setSessionsError(null);
|
||||
try {
|
||||
const fetchedSessions = await getScheduleSessions(sId, 20); // MODIFIED
|
||||
// Assuming ScheduleSession from ../../schedule can be cast or mapped to ScheduleSessionMeta
|
||||
// You may need to transform/map fields if they differ significantly
|
||||
setSessions(fetchedSessions as ScheduleSessionMeta[]);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch schedule sessions:', err);
|
||||
setSessionsError(err instanceof Error ? err.message : 'Failed to fetch schedule sessions');
|
||||
} finally {
|
||||
setIsLoadingSessions(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (scheduleId && !selectedSessionDetails) {
|
||||
fetchScheduleSessions(scheduleId);
|
||||
} else if (!scheduleId) {
|
||||
setSessions([]);
|
||||
setSessionsError(null);
|
||||
setRunNowLoading(false);
|
||||
setSelectedSessionDetails(null);
|
||||
}
|
||||
}, [scheduleId, fetchScheduleSessions, selectedSessionDetails]);
|
||||
|
||||
const handleRunNow = async () => {
|
||||
if (!scheduleId) return;
|
||||
setRunNowLoading(true);
|
||||
try {
|
||||
const newSessionId = await runScheduleNow(scheduleId); // MODIFIED
|
||||
toastSuccess({
|
||||
title: 'Schedule Triggered',
|
||||
msg: `Successfully triggered schedule. New session ID: ${newSessionId}`,
|
||||
});
|
||||
setTimeout(() => {
|
||||
if (scheduleId) fetchScheduleSessions(scheduleId);
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
console.error('Failed to run schedule now:', err);
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to trigger schedule';
|
||||
toastError({ title: 'Run Schedule Error', msg: errorMsg });
|
||||
} finally {
|
||||
setRunNowLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAndShowSessionDetails = async (sessionId: string) => {
|
||||
setIsLoadingSessionDetails(true);
|
||||
setSessionDetailsError(null);
|
||||
setSelectedSessionDetails(null);
|
||||
try {
|
||||
const details = await fetchSessionDetails(sessionId);
|
||||
setSelectedSessionDetails(details);
|
||||
} catch (err) {
|
||||
console.error(`Failed to load session details for ${sessionId}:`, err);
|
||||
const errorMsg = err instanceof Error ? err.message : 'Failed to load session details.';
|
||||
setSessionDetailsError(errorMsg);
|
||||
toastError({
|
||||
title: 'Failed to load session details',
|
||||
msg: errorMsg,
|
||||
});
|
||||
} finally {
|
||||
setIsLoadingSessionDetails(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSessionCardClick = (sessionIdFromCard: string) => {
|
||||
loadAndShowSessionDetails(sessionIdFromCard);
|
||||
};
|
||||
|
||||
const handleResumeViewedSession = () => {
|
||||
if (selectedSessionDetails) {
|
||||
const { session_id, metadata } = selectedSessionDetails;
|
||||
if (metadata.working_dir) {
|
||||
console.log(
|
||||
`Resuming session ID ${session_id} in new chat window. Dir: ${metadata.working_dir}`
|
||||
);
|
||||
window.electron.createChatWindow(undefined, metadata.working_dir, undefined, session_id);
|
||||
} else {
|
||||
console.error('Cannot resume session: working directory is missing.');
|
||||
toastError({ title: 'Cannot Resume Session', msg: 'Working directory is missing.' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (selectedSessionDetails) {
|
||||
return (
|
||||
<SessionHistoryView
|
||||
session={selectedSessionDetails}
|
||||
isLoading={isLoadingSessionDetails}
|
||||
error={sessionDetailsError}
|
||||
onBack={() => {
|
||||
setSelectedSessionDetails(null);
|
||||
setSessionDetailsError(null);
|
||||
}}
|
||||
onResume={handleResumeViewedSession}
|
||||
onRetry={() => loadAndShowSessionDetails(selectedSessionDetails.session_id)}
|
||||
showActionButtons={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!scheduleId) {
|
||||
return (
|
||||
<div className="h-screen w-full flex flex-col items-center justify-center bg-app text-textStandard p-8">
|
||||
<MoreMenuLayout showMenu={false} />
|
||||
<BackButton onClick={onNavigateBack} />
|
||||
<h1 className="text-2xl font-medium text-gray-900 dark:text-white mt-4">
|
||||
Schedule Not Found
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||
No schedule ID was provided. Please return to the schedules list and select a schedule.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full flex flex-col bg-app text-textStandard">
|
||||
<MoreMenuLayout showMenu={false} />
|
||||
<div className="px-8 pt-6 pb-4 border-b border-borderSubtle flex-shrink-0">
|
||||
<BackButton onClick={onNavigateBack} />
|
||||
<h1 className="text-3xl font-medium text-gray-900 dark:text-white mt-1">
|
||||
Schedule Details
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Viewing Schedule ID: {scheduleId}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-grow">
|
||||
<div className="p-8 space-y-6">
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-3">Actions</h2>
|
||||
<Button onClick={handleRunNow} disabled={runNowLoading} className="w-full md:w-auto">
|
||||
{runNowLoading ? 'Triggering...' : 'Run Schedule Now'}
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Recent Sessions for this Schedule
|
||||
</h2>
|
||||
{isLoadingSessions && (
|
||||
<p className="text-gray-500 dark:text-gray-400">Loading sessions...</p>
|
||||
)}
|
||||
{sessionsError && (
|
||||
<p className="text-red-500 dark:text-red-400 text-sm p-3 bg-red-100 dark:bg-red-900/30 border border-red-500 dark:border-red-700 rounded-md">
|
||||
Error: {sessionsError}
|
||||
</p>
|
||||
)}
|
||||
{!isLoadingSessions && !sessionsError && sessions.length === 0 && (
|
||||
<p className="text-gray-500 dark:text-gray-400 text-center py-4">
|
||||
No sessions found for this schedule.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isLoadingSessions && sessions.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{sessions.map((session) => (
|
||||
<Card
|
||||
key={session.id}
|
||||
className="p-4 bg-white dark:bg-gray-800 shadow cursor-pointer hover:shadow-lg transition-shadow duration-200"
|
||||
onClick={() => handleSessionCardClick(session.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
handleSessionCardClick(session.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
className="text-sm font-semibold text-gray-900 dark:text-white truncate"
|
||||
title={session.name || session.id}
|
||||
>
|
||||
{session.name || `Session ID: ${session.id}`}{' '}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Created:{' '}
|
||||
{session.createdAt ? new Date(session.createdAt).toLocaleString() : 'N/A'}
|
||||
</p>
|
||||
{session.messageCount !== undefined && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Messages: {session.messageCount}
|
||||
</p>
|
||||
)}
|
||||
{session.workingDir && (
|
||||
<p
|
||||
className="text-xs text-gray-500 dark:text-gray-400 mt-1 truncate"
|
||||
title={session.workingDir}
|
||||
>
|
||||
Dir: {session.workingDir}
|
||||
</p>
|
||||
)}
|
||||
{session.accumulatedTotalTokens !== undefined &&
|
||||
session.accumulatedTotalTokens !== null && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Tokens: {session.accumulatedTotalTokens}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-600 dark:text-gray-500 mt-1">
|
||||
ID: <span className="font-mono">{session.id}</span>
|
||||
</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScheduleDetailView;
|
||||
@@ -0,0 +1,230 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { listSchedules, createSchedule, deleteSchedule, ScheduledJob } from '../../schedule';
|
||||
import BackButton from '../ui/BackButton';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import MoreMenuLayout from '../more_menu/MoreMenuLayout';
|
||||
import { Card } from '../ui/card';
|
||||
import { Button } from '../ui/button';
|
||||
import { TrashIcon } from '../icons/TrashIcon';
|
||||
import Plus from '../ui/Plus';
|
||||
import { CreateScheduleModal, NewSchedulePayload } from './CreateScheduleModal';
|
||||
import ScheduleDetailView from './ScheduleDetailView';
|
||||
import cronstrue from 'cronstrue';
|
||||
|
||||
interface SchedulesViewProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SchedulesView: React.FC<SchedulesViewProps> = ({ onClose }) => {
|
||||
const [schedules, setSchedules] = useState<ScheduledJob[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
const [submitApiError, setSubmitApiError] = useState<string | null>(null);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
|
||||
const [viewingScheduleId, setViewingScheduleId] = useState<string | null>(null);
|
||||
|
||||
const fetchSchedules = async () => {
|
||||
setIsLoading(true);
|
||||
setApiError(null);
|
||||
try {
|
||||
const fetchedSchedules = await listSchedules();
|
||||
setSchedules(fetchedSchedules);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch schedules:', error);
|
||||
setApiError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'An unknown error occurred while fetching schedules.'
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (viewingScheduleId === null) {
|
||||
fetchSchedules();
|
||||
}
|
||||
}, [viewingScheduleId]);
|
||||
|
||||
const handleOpenCreateModal = () => {
|
||||
setSubmitApiError(null);
|
||||
setIsCreateModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseCreateModal = () => {
|
||||
setIsCreateModalOpen(false);
|
||||
setSubmitApiError(null);
|
||||
};
|
||||
|
||||
const handleCreateScheduleSubmit = async (payload: NewSchedulePayload) => {
|
||||
setIsSubmitting(true);
|
||||
setSubmitApiError(null);
|
||||
try {
|
||||
await createSchedule(payload);
|
||||
await fetchSchedules();
|
||||
setIsCreateModalOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to create schedule:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Unknown error creating schedule.';
|
||||
setSubmitApiError(errorMessage);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSchedule = async (idToDelete: string) => {
|
||||
if (!window.confirm(`Are you sure you want to delete schedule "${idToDelete}"?`)) return;
|
||||
if (viewingScheduleId === idToDelete) {
|
||||
setViewingScheduleId(null);
|
||||
}
|
||||
setIsLoading(true);
|
||||
setApiError(null);
|
||||
try {
|
||||
await deleteSchedule(idToDelete);
|
||||
await fetchSchedules();
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete schedule "${idToDelete}":`, error);
|
||||
setApiError(
|
||||
error instanceof Error ? error.message : `Unknown error deleting "${idToDelete}".`
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNavigateToScheduleDetail = (scheduleId: string) => {
|
||||
setViewingScheduleId(scheduleId);
|
||||
};
|
||||
|
||||
const handleNavigateBackFromDetail = () => {
|
||||
setViewingScheduleId(null);
|
||||
};
|
||||
|
||||
const getReadableCron = (cronString: string) => {
|
||||
try {
|
||||
return cronstrue.toString(cronString);
|
||||
} catch (e) {
|
||||
console.warn(`Could not parse cron string "${cronString}":`, e);
|
||||
return cronString;
|
||||
}
|
||||
};
|
||||
|
||||
if (viewingScheduleId) {
|
||||
return (
|
||||
<ScheduleDetailView
|
||||
scheduleId={viewingScheduleId}
|
||||
onNavigateBack={handleNavigateBackFromDetail}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen w-full flex flex-col bg-app text-textStandard">
|
||||
<MoreMenuLayout showMenu={false} />
|
||||
<div className="px-8 pt-6 pb-4 border-b border-borderSubtle flex-shrink-0">
|
||||
<BackButton onClick={onClose} />
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white mt-2">
|
||||
Schedules Management
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-grow">
|
||||
<div className="p-8">
|
||||
<Button
|
||||
onClick={handleOpenCreateModal}
|
||||
className="w-full md:w-auto flex items-center gap-2 justify-center text-white dark:text-black bg-bgAppInverse hover:bg-bgStandardInverse [&>svg]:!size-4 mb-8"
|
||||
>
|
||||
<Plus className="h-4 w-4" /> Create New Schedule
|
||||
</Button>
|
||||
|
||||
{apiError && (
|
||||
<p className="text-red-500 dark:text-red-400 text-sm p-4 bg-red-100 dark:bg-red-900/30 border border-red-500 dark:border-red-700 rounded-md">
|
||||
Error: {apiError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Existing Schedules
|
||||
</h2>
|
||||
{isLoading && schedules.length === 0 && (
|
||||
<p className="text-gray-500 dark:text-gray-400">Loading schedules...</p>
|
||||
)}
|
||||
{!isLoading && !apiError && schedules.length === 0 && (
|
||||
<p className="text-gray-500 dark:text-gray-400 text-center py-4">
|
||||
No schedules found. Create one to get started!
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isLoading && schedules.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{schedules.map((job) => (
|
||||
<Card
|
||||
key={job.id}
|
||||
className="p-4 bg-white dark:bg-gray-800 shadow cursor-pointer hover:shadow-lg transition-shadow duration-200"
|
||||
onClick={() => handleNavigateToScheduleDetail(job.id)}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-grow mr-2 overflow-hidden">
|
||||
<h3
|
||||
className="text-base font-semibold text-gray-900 dark:text-white truncate"
|
||||
title={job.id}
|
||||
>
|
||||
{job.id}
|
||||
</h3>
|
||||
<p
|
||||
className="text-xs text-gray-500 dark:text-gray-400 mt-1 break-all"
|
||||
title={job.source}
|
||||
>
|
||||
Source: {job.source}
|
||||
</p>
|
||||
<p
|
||||
className="text-xs text-gray-500 dark:text-gray-400 mt-1"
|
||||
title={getReadableCron(job.cron)}
|
||||
>
|
||||
Schedule: {getReadableCron(job.cron)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Last Run:{' '}
|
||||
{job.last_run ? new Date(job.last_run).toLocaleString() : 'Never'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteSchedule(job.id);
|
||||
}}
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-red-500 dark:hover:text-red-400 hover:bg-red-100/50 dark:hover:bg-red-900/30"
|
||||
title={`Delete schedule ${job.id}`}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<TrashIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<CreateScheduleModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={handleCloseCreateModal}
|
||||
onSubmit={handleCreateScheduleSubmit}
|
||||
isLoadingExternally={isSubmitting}
|
||||
apiErrorExternally={submitApiError}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SchedulesView;
|
||||
@@ -26,6 +26,7 @@ interface SessionHistoryViewProps {
|
||||
onBack: () => void;
|
||||
onResume: () => void;
|
||||
onRetry: () => void;
|
||||
showActionButtons?: boolean;
|
||||
}
|
||||
|
||||
const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
|
||||
@@ -35,6 +36,7 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
|
||||
onBack,
|
||||
onResume,
|
||||
onRetry,
|
||||
showActionButtons = true,
|
||||
}) => {
|
||||
const [isShareModalOpen, setIsShareModalOpen] = useState(false);
|
||||
const [shareLink, setShareLink] = useState<string>('');
|
||||
@@ -47,7 +49,6 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
|
||||
if (savedSessionConfig) {
|
||||
try {
|
||||
const config = JSON.parse(savedSessionConfig);
|
||||
// If config.enabled is true and config.baseUrl is non-empty, we can share
|
||||
if (config.enabled && config.baseUrl) {
|
||||
setCanShare(true);
|
||||
}
|
||||
@@ -61,7 +62,6 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
|
||||
setIsSharing(true);
|
||||
|
||||
try {
|
||||
// Get the session sharing configuration from localStorage
|
||||
const savedSessionConfig = localStorage.getItem('session_sharing_config');
|
||||
if (!savedSessionConfig) {
|
||||
throw new Error('Session sharing is not configured. Please configure it in settings.');
|
||||
@@ -72,7 +72,6 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
|
||||
throw new Error('Session sharing is not enabled or base URL is not configured.');
|
||||
}
|
||||
|
||||
// Create a shared session
|
||||
const shareToken = await createSharedSession(
|
||||
config.baseUrl,
|
||||
session.metadata.working_dir,
|
||||
@@ -81,7 +80,6 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
|
||||
session.metadata.total_tokens
|
||||
);
|
||||
|
||||
// Create the shareable link
|
||||
const shareableLink = `goose://sessions/${shareToken}`;
|
||||
setShareLink(shareableLink);
|
||||
setIsShareModalOpen(true);
|
||||
@@ -112,9 +110,7 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
|
||||
<div className="h-screen w-full flex flex-col">
|
||||
<MoreMenuLayout showMenu={false} />
|
||||
|
||||
{/* Top Row - back, info, reopen thread (fixed) */}
|
||||
<SessionHeaderCard onBack={onBack}>
|
||||
{/* Session info row */}
|
||||
<div className="ml-8">
|
||||
<h1 className="text-lg text-textStandardInverse">
|
||||
{session.metadata.description || session.session_id}
|
||||
@@ -143,37 +139,39 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center space-x-4">
|
||||
<button
|
||||
onClick={handleShare}
|
||||
title="Share Session"
|
||||
disabled={!canShare || isSharing}
|
||||
className={`flex items-center text-textStandardInverse px-2 py-1 ${
|
||||
canShare
|
||||
? 'hover:font-bold hover:scale-110 transition-all duration-150'
|
||||
: 'cursor-not-allowed opacity-50'
|
||||
}`}
|
||||
>
|
||||
{isSharing ? (
|
||||
<>
|
||||
<LoaderCircle className="w-7 h-7 animate-spin mr-2" />
|
||||
<span>Sharing...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Share2 className="w-7 h-7" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{showActionButtons && (
|
||||
<div className="ml-auto flex items-center space-x-4">
|
||||
<button
|
||||
onClick={handleShare}
|
||||
title="Share Session"
|
||||
disabled={!canShare || isSharing}
|
||||
className={`flex items-center text-textStandardInverse px-2 py-1 ${
|
||||
canShare
|
||||
? 'hover:font-bold hover:scale-110 transition-all duration-150'
|
||||
: 'cursor-not-allowed opacity-50'
|
||||
}`}
|
||||
>
|
||||
{isSharing ? (
|
||||
<>
|
||||
<LoaderCircle className="w-7 h-7 animate-spin mr-2" />
|
||||
<span>Sharing...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Share2 className="w-7 h-7" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onResume}
|
||||
title="Resume Session"
|
||||
className="flex items-center text-textStandardInverse px-2 py-1 hover:font-bold hover:scale-110 transition-all duration-150"
|
||||
>
|
||||
<Sparkles className="w-7 h-7" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={onResume}
|
||||
title="Resume Session"
|
||||
className="flex items-center text-textStandardInverse px-2 py-1 hover:font-bold hover:scale-110 transition-all duration-150"
|
||||
>
|
||||
<Sparkles className="w-7 h-7" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</SessionHeaderCard>
|
||||
|
||||
<SessionMessages
|
||||
@@ -183,20 +181,16 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
|
||||
{/* Share Link Modal */}
|
||||
<Modal open={isShareModalOpen} onOpenChange={setIsShareModalOpen}>
|
||||
<ModalContent className="sm:max-w-md p-0 bg-bgApp dark:bg-bgApp dark:border-borderSubtle">
|
||||
{/* Share Icon */}
|
||||
<div className="flex justify-center mt-4">
|
||||
<Share2 className="w-6 h-6 text-textStandard" />
|
||||
</div>
|
||||
|
||||
{/* Centered Title */}
|
||||
<div className="mt-2 px-6 text-center">
|
||||
<h2 className="text-lg font-semibold text-textStandard">Share Session (beta)</h2>
|
||||
</div>
|
||||
|
||||
{/* Description & Link */}
|
||||
<div className="px-6 flex flex-col gap-4 mt-2">
|
||||
<p className="text-sm text-center text-textSubtle">
|
||||
Share this session link to give others a read only view of your goose chat.
|
||||
@@ -219,7 +213,6 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user