This is an automated email from the ASF dual-hosted git repository.

zqr10159 pushed a commit to branch 2.0.0
in repository https://gitbox.apache.org/repos/asf/hertzbeat.git

commit c9c310d8f67f011d389119111cd7b8975d18faf9
Author: Logic <[email protected]>
AuthorDate: Fri Aug 14 08:45:06 2026 +0800

    feat(ai): add schedule management UI
---
 .../startup/AgentGatewayMigrationTest.java         |  18 ++-
 web-app/src/app/administrative-app-routes.test.tsx |  27 +++-
 web-app/src/app/app-routes.tsx                     |  13 ++
 .../src/app/refine/refine-resource-registry.tsx    |   6 +
 web-app/src/app/route-registry.test.ts             |  10 ++
 web-app/src/app/route-registry.ts                  |   3 +
 web-app/src/assets/i18n/en-us.json                 |  52 +++++++
 web-app/src/assets/i18n/ja-jp.json                 |  52 +++++++
 web-app/src/assets/i18n/pt-br.json                 |  52 +++++++
 web-app/src/assets/i18n/zh-cn.json                 |  52 +++++++
 web-app/src/assets/i18n/zh-tw.json                 |  52 +++++++
 web-app/src/core/http/api-message.ts               |  10 +-
 .../ai-workspace/api/agent-schedule-api.test.ts    | 135 +++++++++++++++++
 .../ai-workspace/api/agent-schedule-api.ts         | 122 +++++++++++++++
 .../ai-workspace/api/agent-schedule-schema.ts      |  49 ++++++
 .../components/agent-schedule-editor.tsx           | 137 +++++++++++++++++
 .../components/agent-schedule-table.tsx            | 138 +++++++++++++++++
 .../components/agent-schedule-transcript.tsx       |  55 +++++++
 .../components/agent-schedule-view.module.css      |  76 ++++++++++
 .../components/agent-schedule-view.tsx             |  65 ++++++++
 .../components/agent-workspace-session-pane.tsx    |  17 ++-
 .../components/agent-workspace-view.module.css     |   4 +-
 .../components/agent-workspace-view.test.tsx       |   6 +-
 .../components/agent-workspace-view.tsx            |  11 +-
 .../use-agent-schedule-controller.test.tsx         | 104 +++++++++++++
 .../controller/use-agent-schedule-controller.ts    |  36 +++++
 .../controller/use-agent-schedule-editor.ts        | 107 +++++++++++++
 .../controller/use-agent-schedule-list.ts          |  58 +++++++
 .../controller/use-agent-schedule-transcript.ts    |  82 ++++++++++
 web-app/src/features/ai-workspace/index.ts         |   1 +
 .../ai-workspace/model/agent-schedule-contract.ts  |  52 +++++++
 .../model/agent-schedule-view-model.ts             |  60 ++++++++
 .../pages/agent-schedule-page.test.tsx             | 167 +++++++++++++++++++++
 .../ai-workspace/pages/agent-schedule-page.tsx     |  43 ++++++
 .../ai-workspace/pages/agent-workspace-page.tsx    |   4 +
 web-app/src/shared/navigation/app-paths.ts         |   1 +
 36 files changed, 1862 insertions(+), 15 deletions(-)

diff --git 
a/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/AgentGatewayMigrationTest.java
 
b/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/AgentGatewayMigrationTest.java
index b5fd73f524..9da9c127e8 100644
--- 
a/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/AgentGatewayMigrationTest.java
+++ 
b/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/AgentGatewayMigrationTest.java
@@ -31,7 +31,7 @@ import java.sql.Statement;
 import java.util.Locale;
 import org.junit.jupiter.api.Test;
 
-/** Upgrade contract for the non-destructive Agent Gateway schema. */
+/** Contract for the Agent Gateway schema included in the 2.0 foundation. */
 class AgentGatewayMigrationTest {
 
     private static final String[] AGENT_TABLES = {
@@ -39,7 +39,7 @@ class AgentGatewayMigrationTest {
             "hzb_agent_run",
             "hzb_agent_tool_call",
             "hzb_agent_transcript_entry",
-            "hzb_agent_scheduled_command",
+            "hzb_agent_schedule",
             "hzb_alert_analysis_policy"
     };
 
@@ -54,7 +54,7 @@ class AgentGatewayMigrationTest {
                 statement.execute("CREATE TABLE hzb_sop_schedule (id BIGINT 
PRIMARY KEY)");
                 statement.execute("INSERT INTO hzb_ai_conversation(id) VALUES 
(1)");
 
-                for (String sql : migration("h2").split(";")) {
+                for (String sql : gatewaySchema(migration("h2")).split(";")) {
                     if (!sql.isBlank()) {
                         statement.execute(sql);
                     }
@@ -66,6 +66,8 @@ class AgentGatewayMigrationTest {
                     assertTrue(tableExists(statement, table), table);
                 }
                 assertTrue(columnExists(statement, "hzb_agent_run", 
"target_context_json"));
+                assertTrue(columnExists(statement, "hzb_agent_run", 
"entry_type"));
+                assertTrue(columnExists(statement, "hzb_agent_session", 
"origin_entry_type"));
             }
         }
     }
@@ -114,10 +116,18 @@ class AgentGatewayMigrationTest {
     }
 
     private String migration(String database) throws IOException {
-        String path = "/db/migration/" + database + 
"/V206__add_agent_gateway.sql";
+        String path = "/db/migration/" + database + 
"/V200__create_entity_foundation.sql";
         try (var input = getClass().getResourceAsStream(path)) {
             assertNotNull(input, path);
             return new String(input.readAllBytes(), StandardCharsets.UTF_8);
         }
     }
+
+    private String gatewaySchema(String migration) {
+        int start = migration.indexOf("CREATE TABLE IF NOT EXISTS 
hzb_agent_session");
+        int end = migration.indexOf("ALTER TABLE hzb_config ALTER COLUMN 
content CLOB", start);
+        assertTrue(start >= 0, "agent session schema");
+        assertTrue(end > start, "agent gateway schema boundary");
+        return migration.substring(start, end);
+    }
 }
diff --git a/web-app/src/app/administrative-app-routes.test.tsx 
b/web-app/src/app/administrative-app-routes.test.tsx
index f8fe189657..c86499b47c 100644
--- a/web-app/src/app/administrative-app-routes.test.tsx
+++ b/web-app/src/app/administrative-app-routes.test.tsx
@@ -18,6 +18,7 @@ import { i18n, initializeI18n, loadLocale } from 
'@/core/i18n/i18n';
 import { appRoutes } from './app-routes';
 
 const probes = vi.hoisted(() => ({
+  agentScheduleApi: vi.fn(),
   deploymentApi: vi.fn(),
   deploymentLoader: vi.fn(),
   pluginApi: vi.fn(),
@@ -26,6 +27,19 @@ const probes = vi.hoisted(() => ({
   tokenLoader: vi.fn()
 }));
 
+vi.mock('@/features/ai-workspace', async () => {
+  const React = await import('react');
+  return {
+    AgentWorkspacePage: () => React.createElement('div', { 'data-testid': 
'ai-workspace-page' }),
+    AgentSchedulePage: () => {
+      React.useEffect(() => {
+        probes.agentScheduleApi();
+      }, []);
+      return React.createElement('div', { 'data-testid': 
'agent-schedules-page' });
+    }
+  };
+});
+
 vi.mock('@/features/setup', () => ({
   SetupPage: () => null
 }));
@@ -97,6 +111,7 @@ describe('actual administrative app routes', () => {
     probes.pluginLoader.mockClear();
     probes.tokenApi.mockClear();
     probes.tokenLoader.mockClear();
+    probes.agentScheduleApi.mockClear();
   });
 
   it.each([
@@ -105,7 +120,9 @@ describe('actual administrative app routes', () => {
     ['/settings/plugins', 'USER'],
     ['/settings/plugins', 'GUEST'],
     ['/settings/deployment', 'USER'],
-    ['/settings/deployment', 'GUEST']
+    ['/settings/deployment', 'GUEST'],
+    ['/ai/schedules', 'USER'],
+    ['/ai/schedules', 'GUEST']
   ])('does not mount the feature loader or API at %s for %s', async (path, 
role) => {
     renderAppRoute(path, role);
 
@@ -119,6 +136,7 @@ describe('actual administrative app routes', () => {
     expect(probes.pluginApi).not.toHaveBeenCalled();
     expect(probes.deploymentLoader).not.toHaveBeenCalled();
     expect(probes.deploymentApi).not.toHaveBeenCalled();
+    expect(probes.agentScheduleApi).not.toHaveBeenCalled();
   });
 
   it('admits ADMIN to the Token loader and page API', async () => {
@@ -145,6 +163,13 @@ describe('actual administrative app routes', () => {
     await waitFor(() => expect(probes.deploymentApi).toHaveBeenCalledOnce());
   });
 
+  it('admits ADMIN to the guarded Agent schedule page API', async () => {
+    renderAppRoute('/ai/schedules', 'ADMIN');
+
+    expect(await 
screen.findByTestId('agent-schedules-page')).toBeInTheDocument();
+    await waitFor(() => 
expect(probes.agentScheduleApi).toHaveBeenCalledOnce());
+  });
+
   it('converges the legacy Plugin path on the guarded canonical route', async 
() => {
     renderAppRoute('/setting/plugin', 'USER');
 
diff --git a/web-app/src/app/app-routes.tsx b/web-app/src/app/app-routes.tsx
index ea61a8fb7e..e59a0d787b 100644
--- a/web-app/src/app/app-routes.tsx
+++ b/web-app/src/app/app-routes.tsx
@@ -131,6 +131,19 @@ export const appRoutes: RouteObject[] = [
                   }
                 ]
               },
+              {
+                ...getAppRouteIdentity('ai-schedules'),
+                element: <ResourceRouteAccess routeId="ai-schedules" />,
+                children: [
+                  {
+                    index: true,
+                    lazy: async () => {
+                      const { AgentSchedulePage } = await 
import('@/features/ai-workspace');
+                      return { Component: AgentSchedulePage };
+                    }
+                  }
+                ]
+              },
               {
                 ...getAppRouteIdentity('monitors'),
                 lazy: async () => {
diff --git a/web-app/src/app/refine/refine-resource-registry.tsx 
b/web-app/src/app/refine/refine-resource-registry.tsx
index 5d7d659393..62bbb4a2b9 100644
--- a/web-app/src/app/refine/refine-resource-registry.tsx
+++ b/web-app/src/app/refine/refine-resource-registry.tsx
@@ -146,6 +146,12 @@ const staticRefineResources: ResourceProps[] = [
     order: 6,
     timePolicy: 'none'
   }),
+  routedNavigationResource('ai-schedules', {
+    navigation: false,
+    icon: <ScheduleOutlined />,
+    order: 0,
+    timePolicy: 'none'
+  }),
   routedNavigationResource('monitors', {
     parent: 'shell-basic-monitoring',
     icon: <RadarChartOutlined />,
diff --git a/web-app/src/app/route-registry.test.ts 
b/web-app/src/app/route-registry.test.ts
index 2019929d3b..18db8debb7 100644
--- a/web-app/src/app/route-registry.test.ts
+++ b/web-app/src/app/route-registry.test.ts
@@ -248,6 +248,15 @@ describe('route registry', () => {
     expectTypeOf<'ai-workspace'>().toMatchTypeOf<AppResourceRouteId>();
   });
 
+  it('keeps system Agent schedules inside the administrative AI workspace', () 
=> {
+    expect(getAppRoute('ai-schedules')).toMatchObject({
+      id: 'ai-schedules',
+      path: '/ai/schedules',
+      kind: 'page',
+      resource: { labelKey: 'aiSchedules.title', requiredRoles: ['ADMIN'] }
+    });
+  });
+
   it('registers existing resource definition editing centrally', () => {
     expect(getAppRoute('entity-definition')).toMatchObject({
       id: 'entity-definition',
@@ -340,6 +349,7 @@ describe('route registry', () => {
     expect(getAppRoute('labels').resource).toMatchObject({ requiredRoles: 
['ADMIN', 'USER', 'GUEST'] });
     expect(routeRegistry.flatMap(route => (route.resource?.requiredRoles ? 
[route.id] : []))).toEqual([
       'ai-workspace',
+      'ai-schedules',
       'instrumentation',
       'tokens',
       'plugins',
diff --git a/web-app/src/app/route-registry.ts 
b/web-app/src/app/route-registry.ts
index 22b4923bbc..abcee16d6c 100644
--- a/web-app/src/app/route-registry.ts
+++ b/web-app/src/app/route-registry.ts
@@ -50,6 +50,9 @@ export const appRouteCatalog = {
   'ai-workspace': pageRoute('ai-workspace', applicationRoutePaths.aiWorkspace, 
{
     resource: { labelKey: 'menu.aiWorkspace', requiredRoles: ['ADMIN', 'USER'] 
}
   }),
+  'ai-schedules': pageRoute('ai-schedules', applicationRoutePaths.aiSchedules, 
{
+    resource: { labelKey: 'aiSchedules.title', requiredRoles: ['ADMIN'] }
+  }),
   monitors: pageRoute('monitors', monitorRoutePaths.list, {
     resource: { labelKey: 'menu.monitors' }
   }),
diff --git a/web-app/src/assets/i18n/en-us.json 
b/web-app/src/assets/i18n/en-us.json
index 2b57aca3cb..8e41949fd4 100644
--- a/web-app/src/assets/i18n/en-us.json
+++ b/web-app/src/assets/i18n/en-us.json
@@ -3320,5 +3320,57 @@
       "apiKeyHint": "Leave blank to keep the existing key.",
       "save": "Save provider"
     }
+  },
+  "aiSchedules": {
+    "title": "Scheduled investigations",
+    "description": "Run a fixed investigation on a schedule and deliver the 
result through configured notification receivers.",
+    "workspaceAction": "Scheduled investigations",
+    "create": "Create schedule",
+    "never": "Not scheduled",
+    "receiverCount": "{{count}} receivers",
+    "columns": {
+      "schedule": "Schedule",
+      "instruction": "Investigation instruction",
+      "delivery": "Delivery",
+      "nextRun": "Next run",
+      "enabled": "Enabled",
+      "actions": "Actions"
+    },
+    "actions": {
+      "runNow": "Run now",
+      "transcript": "Run history",
+      "edit": "Edit",
+      "delete": "Delete",
+      "deleteConfirm": "Delete this schedule?"
+    },
+    "states": {
+      "loading": "Loading scheduled investigations…",
+      "empty": "No scheduled investigations yet.",
+      "emptyDescription": "Create a schedule to run the same investigation and 
notify the same receivers automatically.",
+      "unavailable": "Scheduled investigations are unavailable.",
+      "mutationFailed": "The schedule change could not be confirmed. Refresh 
before trying again."
+    },
+    "editor": {
+      "createTitle": "Create scheduled investigation",
+      "editTitle": "Edit scheduled investigation",
+      "name": "Name",
+      "instruction": "Investigation instruction",
+      "cron": "Schedule expression",
+      "cronHint": "Six-field Spring cron; seconds must be 0",
+      "receivers": "Notification receivers",
+      "template": "Notification template",
+      "templateHint": "A custom template requires receivers of one type",
+      "defaultTemplate": "Use each receiver type's default template",
+      "enabled": "Enable after saving",
+      "save": "Save schedule"
+    },
+    "transcript": {
+      "title": "Run history · {{name}}",
+      "loading": "Loading run history…",
+      "empty": "This schedule has not produced a transcript yet.",
+      "unavailable": "Run history is unavailable.",
+      "loadEarlier": "Load earlier runs",
+      "noText": "No text content"
+    }
   }
 }
diff --git a/web-app/src/assets/i18n/ja-jp.json 
b/web-app/src/assets/i18n/ja-jp.json
index 85a565ea33..a3f16a2376 100644
--- a/web-app/src/assets/i18n/ja-jp.json
+++ b/web-app/src/assets/i18n/ja-jp.json
@@ -3314,5 +3314,57 @@
       "apiKeyHint": "空欄にすると既存のキーを維持します。",
       "save": "プロバイダーを保存"
     }
+  },
+  "aiSchedules": {
+    "title": "定期調査",
+    "description": "決まった調査をスケジュール実行し、設定済みの通知先へ結果を送信します。",
+    "workspaceAction": "定期調査",
+    "create": "定期調査を作成",
+    "never": "未設定",
+    "receiverCount": "{{count}} 件の通知先",
+    "columns": {
+      "schedule": "スケジュール",
+      "instruction": "調査指示",
+      "delivery": "通知",
+      "nextRun": "次回実行",
+      "enabled": "有効",
+      "actions": "操作"
+    },
+    "actions": {
+      "runNow": "今すぐ実行",
+      "transcript": "実行履歴",
+      "edit": "編集",
+      "delete": "削除",
+      "deleteConfirm": "この定期調査を削除しますか?"
+    },
+    "states": {
+      "loading": "定期調査を読み込んでいます…",
+      "empty": "定期調査はまだありません。",
+      "emptyDescription": "同じ調査を定期実行し、指定した通知先へ送信できます。",
+      "unavailable": "定期調査を利用できません。",
+      "mutationFailed": "変更を確認できませんでした。更新してから再試行してください。"
+    },
+    "editor": {
+      "createTitle": "定期調査を作成",
+      "editTitle": "定期調査を編集",
+      "name": "名前",
+      "instruction": "調査指示",
+      "cron": "実行スケジュール",
+      "cronHint": "6 フィールドの Spring cron。秒は 0 にしてください",
+      "receivers": "通知先",
+      "template": "通知テンプレート",
+      "templateHint": "カスタムテンプレートでは通知先の種別を統一してください",
+      "defaultTemplate": "通知先種別ごとの既定テンプレートを使用",
+      "enabled": "保存後に有効化",
+      "save": "定期調査を保存"
+    },
+    "transcript": {
+      "title": "実行履歴 · {{name}}",
+      "loading": "実行履歴を読み込んでいます…",
+      "empty": "この定期調査には実行履歴がありません。",
+      "unavailable": "実行履歴を利用できません。",
+      "loadEarlier": "以前の履歴を読み込む",
+      "noText": "テキスト内容なし"
+    }
   }
 }
diff --git a/web-app/src/assets/i18n/pt-br.json 
b/web-app/src/assets/i18n/pt-br.json
index 53018f76c6..90f74309fd 100644
--- a/web-app/src/assets/i18n/pt-br.json
+++ b/web-app/src/assets/i18n/pt-br.json
@@ -3327,5 +3327,57 @@
       "apiKeyHint": "Deixe em branco para manter a chave existente.",
       "save": "Salvar provedor"
     }
+  },
+  "aiSchedules": {
+    "title": "Investigações agendadas",
+    "description": "Execute uma investigação fixa por agenda e envie o 
resultado aos destinatários configurados.",
+    "workspaceAction": "Investigações agendadas",
+    "create": "Criar agendamento",
+    "never": "Não agendado",
+    "receiverCount": "{{count}} destinatários",
+    "columns": {
+      "schedule": "Agendamento",
+      "instruction": "Instrução da investigação",
+      "delivery": "Entrega",
+      "nextRun": "Próxima execução",
+      "enabled": "Ativo",
+      "actions": "Ações"
+    },
+    "actions": {
+      "runNow": "Executar agora",
+      "transcript": "Histórico",
+      "edit": "Editar",
+      "delete": "Excluir",
+      "deleteConfirm": "Excluir este agendamento?"
+    },
+    "states": {
+      "loading": "Carregando investigações agendadas…",
+      "empty": "Ainda não há investigações agendadas.",
+      "emptyDescription": "Crie um agendamento para repetir a investigação e 
notificar os mesmos destinatários.",
+      "unavailable": "As investigações agendadas estão indisponíveis.",
+      "mutationFailed": "Não foi possível confirmar a alteração. Atualize 
antes de tentar novamente."
+    },
+    "editor": {
+      "createTitle": "Criar investigação agendada",
+      "editTitle": "Editar investigação agendada",
+      "name": "Nome",
+      "instruction": "Instrução da investigação",
+      "cron": "Expressão de agendamento",
+      "cronHint": "Cron Spring de seis campos; os segundos devem ser 0",
+      "receivers": "Destinatários da notificação",
+      "template": "Modelo de notificação",
+      "templateHint": "Um modelo personalizado requer destinatários do mesmo 
tipo",
+      "defaultTemplate": "Usar o modelo padrão de cada tipo de destinatário",
+      "enabled": "Ativar após salvar",
+      "save": "Salvar agendamento"
+    },
+    "transcript": {
+      "title": "Histórico · {{name}}",
+      "loading": "Carregando histórico…",
+      "empty": "Este agendamento ainda não gerou um histórico.",
+      "unavailable": "O histórico está indisponível.",
+      "loadEarlier": "Carregar histórico anterior",
+      "noText": "Sem conteúdo de texto"
+    }
   }
 }
diff --git a/web-app/src/assets/i18n/zh-cn.json 
b/web-app/src/assets/i18n/zh-cn.json
index 4d3ee6a484..dc1499a6d2 100644
--- a/web-app/src/assets/i18n/zh-cn.json
+++ b/web-app/src/assets/i18n/zh-cn.json
@@ -3295,5 +3295,57 @@
       "apiKeyHint": "留空可保留现有密钥。",
       "save": "保存提供方"
     }
+  },
+  "aiSchedules": {
+    "title": "定时调查",
+    "description": "按计划执行固定调查,并通过已配置的通知接收人发送结果。",
+    "workspaceAction": "定时调查",
+    "create": "新建定时调查",
+    "never": "未安排",
+    "receiverCount": "{{count}} 个接收人",
+    "columns": {
+      "schedule": "任务",
+      "instruction": "调查指令",
+      "delivery": "通知",
+      "nextRun": "下次执行",
+      "enabled": "启用",
+      "actions": "操作"
+    },
+    "actions": {
+      "runNow": "立即执行",
+      "transcript": "执行记录",
+      "edit": "编辑",
+      "delete": "删除",
+      "deleteConfirm": "删除这个定时调查吗?"
+    },
+    "states": {
+      "loading": "正在加载定时调查…",
+      "empty": "还没有定时调查。",
+      "emptyDescription": "创建后可按计划重复执行同一调查,并通知指定接收人。",
+      "unavailable": "定时调查暂不可用。",
+      "mutationFailed": "无法确认本次变更,请刷新后重试。"
+    },
+    "editor": {
+      "createTitle": "新建定时调查",
+      "editTitle": "编辑定时调查",
+      "name": "名称",
+      "instruction": "调查指令",
+      "cron": "执行计划",
+      "cronHint": "使用六段 Spring cron,秒必须为 0",
+      "receivers": "通知接收人",
+      "template": "通知模板",
+      "templateHint": "自定义模板要求所有接收人类型一致",
+      "defaultTemplate": "使用各接收人类型的默认模板",
+      "enabled": "保存后启用",
+      "save": "保存定时调查"
+    },
+    "transcript": {
+      "title": "执行记录 · {{name}}",
+      "loading": "正在加载执行记录…",
+      "empty": "这个定时调查还没有执行记录。",
+      "unavailable": "执行记录暂不可用。",
+      "loadEarlier": "加载更早记录",
+      "noText": "没有文本内容"
+    }
   }
 }
diff --git a/web-app/src/assets/i18n/zh-tw.json 
b/web-app/src/assets/i18n/zh-tw.json
index fe416046bd..152dbc66b6 100644
--- a/web-app/src/assets/i18n/zh-tw.json
+++ b/web-app/src/assets/i18n/zh-tw.json
@@ -3295,5 +3295,57 @@
       "apiKeyHint": "留空可保留現有金鑰。",
       "save": "儲存提供方"
     }
+  },
+  "aiSchedules": {
+    "title": "定時調查",
+    "description": "依排程執行固定調查,並透過已設定的通知接收者傳送結果。",
+    "workspaceAction": "定時調查",
+    "create": "新增定時調查",
+    "never": "未排程",
+    "receiverCount": "{{count}} 位接收者",
+    "columns": {
+      "schedule": "任務",
+      "instruction": "調查指令",
+      "delivery": "通知",
+      "nextRun": "下次執行",
+      "enabled": "啟用",
+      "actions": "操作"
+    },
+    "actions": {
+      "runNow": "立即執行",
+      "transcript": "執行記錄",
+      "edit": "編輯",
+      "delete": "刪除",
+      "deleteConfirm": "刪除這個定時調查嗎?"
+    },
+    "states": {
+      "loading": "正在載入定時調查…",
+      "empty": "尚無定時調查。",
+      "emptyDescription": "建立後可依排程重複執行相同調查,並通知指定接收者。",
+      "unavailable": "定時調查暫時無法使用。",
+      "mutationFailed": "無法確認本次變更,請重新整理後再試。"
+    },
+    "editor": {
+      "createTitle": "新增定時調查",
+      "editTitle": "編輯定時調查",
+      "name": "名稱",
+      "instruction": "調查指令",
+      "cron": "執行排程",
+      "cronHint": "使用六段 Spring cron,秒必須為 0",
+      "receivers": "通知接收者",
+      "template": "通知範本",
+      "templateHint": "自訂範本要求所有接收者類型一致",
+      "defaultTemplate": "使用各接收者類型的預設範本",
+      "enabled": "儲存後啟用",
+      "save": "儲存定時調查"
+    },
+    "transcript": {
+      "title": "執行記錄 · {{name}}",
+      "loading": "正在載入執行記錄…",
+      "empty": "這個定時調查尚無執行記錄。",
+      "unavailable": "執行記錄暫時無法使用。",
+      "loadEarlier": "載入更早記錄",
+      "noText": "沒有文字內容"
+    }
   }
 }
diff --git a/web-app/src/core/http/api-message.ts 
b/web-app/src/core/http/api-message.ts
index ceecdef809..5f2edb3490 100644
--- a/web-app/src/core/http/api-message.ts
+++ b/web-app/src/core/http/api-message.ts
@@ -88,6 +88,14 @@ export function apiMessagePut(
   return apiMessageRequest(path, jsonRequest('PUT', data, options));
 }
 
+export function apiMessagePatch(
+  path: string,
+  data: unknown,
+  options?: Pick<RequestInit, 'signal' | 'headers'>
+): Promise<unknown> {
+  return apiMessageRequest(path, jsonRequest('PATCH', data, options));
+}
+
 export function apiMessageDelete(path: string, options?: Pick<RequestInit, 
'signal' | 'headers'>): Promise<unknown> {
   return apiMessageRequest(path, { ...options, method: 'DELETE' });
 }
@@ -128,7 +136,7 @@ async function parseApiEnvelope(response: Response) {
 }
 
 function jsonRequest(
-  method: 'POST' | 'PUT',
+  method: 'POST' | 'PUT' | 'PATCH',
   data: unknown,
   options?: Pick<RequestInit, 'signal' | 'headers'>
 ): RequestInit {
diff --git a/web-app/src/features/ai-workspace/api/agent-schedule-api.test.ts 
b/web-app/src/features/ai-workspace/api/agent-schedule-api.test.ts
new file mode 100644
index 0000000000..8e05830935
--- /dev/null
+++ b/web-app/src/features/ai-workspace/api/agent-schedule-api.test.ts
@@ -0,0 +1,135 @@
+/* Licensed to the Apache Software Foundation (ASF) under the Apache License, 
Version 2.0. */
+
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import {
+  createAgentSchedule,
+  deleteAgentSchedule,
+  listAgentScheduleOptions,
+  listAgentScheduleTranscript,
+  listAgentSchedules,
+  runAgentSchedule,
+  toggleAgentSchedule,
+  updateAgentSchedule
+} from './agent-schedule-api';
+
+const schedule = {
+  id: 7,
+  name: 'Daily inspection',
+  instruction: 'Inspect critical services',
+  cronExpression: '0 0 9 * * *',
+  enabled: true,
+  sessionId: 11,
+  receiverIds: [3],
+  templateId: null,
+  createdFromSessionUid: null,
+  lastTriggerAt: null,
+  nextTriggerAt: 1_800_000_000_000,
+  creator: 'admin',
+  modifier: 'admin',
+  gmtCreate: '2026-08-14T08:00:00',
+  gmtUpdate: '2026-08-14T08:00:00'
+};
+
+describe('Agent schedule browser API', () => {
+  afterEach(() => vi.unstubAllGlobals());
+
+  it('uses exact schedule CRUD, toggle, and run endpoints', async () => {
+    const fetchMock = vi
+      .fn<typeof fetch>()
+      .mockResolvedValueOnce(message(page([schedule])))
+      .mockResolvedValueOnce(message(schedule))
+      .mockResolvedValueOnce(message({ ...schedule, name: 'Updated' }))
+      .mockResolvedValueOnce(message({ ...schedule, enabled: false }))
+      .mockResolvedValueOnce(message({ runUid: 'run-1', status: 'CREATED' }))
+      .mockResolvedValueOnce(message(null));
+    vi.stubGlobal('fetch', fetchMock);
+
+    await listAgentSchedules(0, 20);
+    await createAgentSchedule(schedule);
+    await updateAgentSchedule(7, { ...schedule, name: 'Updated' });
+    await toggleAgentSchedule(7, false);
+    await runAgentSchedule(7);
+    await deleteAgentSchedule(7);
+
+    expect(fetchMock.mock.calls.map(call => [call[0], call[1]?.method ?? 
'GET'])).toEqual([
+      ['/api/agent/schedules?pageIndex=0&pageSize=20', 'GET'],
+      ['/api/agent/schedules', 'POST'],
+      ['/api/agent/schedules/7', 'PUT'],
+      ['/api/agent/schedules/7/enabled?enabled=false', 'PATCH'],
+      ['/api/agent/schedules/7/run', 'POST'],
+      ['/api/agent/schedules/7', 'DELETE']
+    ]);
+  });
+
+  it('loads receiver and template choices without exposing their secret 
fields', async () => {
+    const fetchMock = vi
+      .fn<typeof fetch>()
+      .mockResolvedValueOnce(message([{ id: 3, name: 'Operations', type: 1 }]))
+      .mockResolvedValueOnce(message([{ id: 5, name: 'AI report', type: 1, 
preset: false, content: 'private' }]));
+    vi.stubGlobal('fetch', fetchMock);
+
+    const options = await listAgentScheduleOptions();
+
+    expect(options).toEqual({
+      receivers: [{ id: 3, name: 'Operations', type: 1 }],
+      templates: [{ id: 5, name: 'AI report', type: 1 }]
+    });
+    expect(fetchMock.mock.calls.map(call => call[0])).toEqual([
+      '/api/notice/receivers/all',
+      '/api/notice/templates/all'
+    ]);
+  });
+
+  it('loads the fixed schedule transcript and exposes text blocks only', async 
() => {
+    const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce(
+      message(
+        page([
+          {
+            id: 19,
+            sessionSequence: 2,
+            messageRole: 'ASSISTANT',
+            payloadJson: JSON.stringify({
+              role: 'assistant',
+              content: [
+                { type: 'text', text: 'Database saturation is stable.' },
+                { type: 'tool_result', text: 'private tool payload' }
+              ]
+            }),
+            gmtCreate: '2026-08-14T09:00:00'
+          }
+        ])
+      )
+    );
+    vi.stubGlobal('fetch', fetchMock);
+
+    await expect(listAgentScheduleTranscript(7)).resolves.toEqual({
+      entries: [
+        {
+          id: 19,
+          sequence: 2,
+          role: 'assistant',
+          text: 'Database saturation is stable.',
+          createdAt: '2026-08-14T09:00:00'
+        }
+      ],
+      pageIndex: 0,
+      hasEarlier: false
+    });
+    expect(fetchMock).toHaveBeenCalledWith(
+      '/api/agent/schedules/7/transcript?pageIndex=0&pageSize=20',
+      expect.objectContaining({ credentials: 'same-origin' })
+    );
+  });
+});
+
+function page(content: unknown[]) {
+  return { content, totalElements: content.length, totalPages: 1, number: 0, 
size: 20 };
+}
+
+function message(data: unknown) {
+  return new Response(JSON.stringify({ code: 0, msg: null, data }), {
+    status: 200,
+    headers: { 'Content-Type': 'application/json' }
+  });
+}
diff --git a/web-app/src/features/ai-workspace/api/agent-schedule-api.ts 
b/web-app/src/features/ai-workspace/api/agent-schedule-api.ts
new file mode 100644
index 0000000000..641ac9c260
--- /dev/null
+++ b/web-app/src/features/ai-workspace/api/agent-schedule-api.ts
@@ -0,0 +1,122 @@
+/* Licensed to the Apache Software Foundation (ASF) under the Apache License, 
Version 2.0. */
+
+import {
+  apiMessageDelete,
+  apiMessageGet,
+  apiMessagePatch,
+  apiMessagePost,
+  apiMessagePut
+} from '@/core/http/api-message';
+
+import type {
+  AgentScheduleDraft,
+  AgentScheduleOptions,
+  AgentScheduleTranscriptEntry,
+  AgentScheduleTranscriptPage
+} from '../model/agent-schedule-contract';
+import {
+  agentSchedulePageSchema,
+  agentScheduleRunSchema,
+  agentScheduleSchema,
+  agentScheduleTemplateSchema,
+  agentScheduleTranscriptSchema,
+  agentScheduleOptionSchema
+} from './agent-schedule-schema';
+
+const schedulesPath = '/api/agent/schedules';
+
+export function listAgentSchedules(pageIndex: number, pageSize: number, 
signal?: AbortSignal) {
+  return parse(
+    agentSchedulePageSchema,
+    
apiMessageGet(`${schedulesPath}?pageIndex=${pageIndex}&pageSize=${pageSize}`, 
signal ? { signal } : {})
+  );
+}
+
+export function createAgentSchedule(draft: AgentScheduleDraft) {
+  return parse(agentScheduleSchema, apiMessagePost(schedulesPath, 
payload(draft)));
+}
+
+export function updateAgentSchedule(scheduleId: number, draft: 
AgentScheduleDraft) {
+  return parse(agentScheduleSchema, 
apiMessagePut(`${schedulesPath}/${scheduleId}`, payload(draft)));
+}
+
+export function toggleAgentSchedule(scheduleId: number, enabled: boolean) {
+  return parse(agentScheduleSchema, 
apiMessagePatch(`${schedulesPath}/${scheduleId}/enabled?enabled=${enabled}`, 
{}));
+}
+
+export function runAgentSchedule(scheduleId: number) {
+  return parse(agentScheduleRunSchema, 
apiMessagePost(`${schedulesPath}/${scheduleId}/run`, {}));
+}
+
+export async function deleteAgentSchedule(scheduleId: number) {
+  await apiMessageDelete(`${schedulesPath}/${scheduleId}`);
+}
+
+export async function listAgentScheduleOptions(signal?: AbortSignal): 
Promise<AgentScheduleOptions> {
+  const request = signal ? { signal } : {};
+  const [receiverValue, templateValue] = await Promise.all([
+    apiMessageGet('/api/notice/receivers/all', request),
+    apiMessageGet('/api/notice/templates/all', request)
+  ]);
+  const receivers = agentScheduleOptionSchema.array().parse(receiverValue);
+  const templates = agentScheduleTemplateSchema
+    .array()
+    .parse(templateValue)
+    .flatMap(option => (option.id == null ? [] : [{ id: option.id, name: 
option.name, type: option.type }]));
+  return { receivers, templates };
+}
+
+export async function listAgentScheduleTranscript(
+  scheduleId: number,
+  pageIndex = 0,
+  pageSize = 20,
+  signal?: AbortSignal
+): Promise<AgentScheduleTranscriptPage> {
+  const value = await apiMessageGet(
+    
`${schedulesPath}/${scheduleId}/transcript?pageIndex=${pageIndex}&pageSize=${pageSize}`,
+    signal ? { signal } : {}
+  );
+  const page = agentScheduleTranscriptSchema.parse(value);
+  return {
+    entries: page.content.flatMap(entry => transcriptEntry(entry)),
+    pageIndex: page.number,
+    hasEarlier: page.number + 1 < page.totalPages
+  };
+}
+
+function payload(draft: AgentScheduleDraft): AgentScheduleDraft {
+  return {
+    name: draft.name.trim(),
+    instruction: draft.instruction.trim(),
+    cronExpression: draft.cronExpression.trim(),
+    enabled: draft.enabled,
+    receiverIds: [...draft.receiverIds],
+    templateId: draft.templateId
+  };
+}
+
+async function parse<T>(schema: { parse: (value: unknown) => T }, value: 
Promise<unknown>): Promise<T> {
+  return schema.parse(await value);
+}
+
+function transcriptEntry(entry: {
+  id: number;
+  sessionSequence: number;
+  payloadJson: string;
+  gmtCreate: string | null;
+}): AgentScheduleTranscriptEntry[] {
+  try {
+    const payload = JSON.parse(entry.payloadJson) as {
+      role?: unknown;
+      content?: Array<{ type?: string; text?: unknown }>;
+    };
+    if (payload.role !== 'user' && payload.role !== 'assistant') return [];
+    const role = payload.role;
+    const text = (payload.content ?? [])
+      .flatMap(block => (block.type === 'text' && typeof block.text === 
'string' ? [block.text] : []))
+      .join('\n');
+    return text ? [{ id: entry.id, sequence: entry.sessionSequence, role, 
text, createdAt: entry.gmtCreate }] : [];
+  } catch {
+    return [];
+  }
+}
diff --git a/web-app/src/features/ai-workspace/api/agent-schedule-schema.ts 
b/web-app/src/features/ai-workspace/api/agent-schedule-schema.ts
new file mode 100644
index 0000000000..ad0dde7ad5
--- /dev/null
+++ b/web-app/src/features/ai-workspace/api/agent-schedule-schema.ts
@@ -0,0 +1,49 @@
+/* Licensed to the Apache Software Foundation (ASF) under the Apache License, 
Version 2.0. */
+
+import { z } from 'zod';
+
+import { springPageSchema } from './agent-gateway-schema';
+
+export const agentScheduleSchema = z
+  .object({
+    id: z.number().int().positive(),
+    name: z.string().min(1),
+    instruction: z.string().min(1),
+    cronExpression: z.string().min(1),
+    enabled: z.boolean(),
+    sessionId: z.number().int().positive().nullable(),
+    receiverIds: z.array(z.number().int().positive()),
+    templateId: z.number().int().positive().nullable(),
+    createdFromSessionUid: z.string().nullable(),
+    lastTriggerAt: z.number().int().nonnegative().nullable(),
+    nextTriggerAt: z.number().int().nonnegative().nullable(),
+    creator: z.string().nullable(),
+    modifier: z.string().nullable(),
+    gmtCreate: z.string().nullable(),
+    gmtUpdate: z.string().nullable()
+  })
+  .passthrough();
+
+export const agentSchedulePageSchema = springPageSchema(agentScheduleSchema);
+
+export const agentScheduleOptionSchema = z
+  .object({ id: z.number().int().positive(), name: z.string().min(1), type: 
z.number().int().nonnegative() })
+  .passthrough();
+
+export const agentScheduleTemplateSchema = z
+  .object({ id: z.number().int().positive().nullable(), name: 
z.string().min(1), type: z.number().int().nonnegative() })
+  .passthrough();
+
+export const agentScheduleRunSchema = z.object({ runUid: z.string().min(1), 
status: z.string().min(1) }).passthrough();
+
+export const agentScheduleTranscriptSchema = springPageSchema(
+  z
+    .object({
+      id: z.number().int().positive(),
+      sessionSequence: z.number().int().positive(),
+      payloadJson: z.string(),
+      messageRole: z.string().min(1),
+      gmtCreate: z.string().nullable()
+    })
+    .passthrough()
+);
diff --git 
a/web-app/src/features/ai-workspace/components/agent-schedule-editor.tsx 
b/web-app/src/features/ai-workspace/components/agent-schedule-editor.tsx
new file mode 100644
index 0000000000..a16946cbad
--- /dev/null
+++ b/web-app/src/features/ai-workspace/components/agent-schedule-editor.tsx
@@ -0,0 +1,137 @@
+/* Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0.
+ */
+
+import { Input, Modal, Select, Switch, Typography } from 'antd';
+import { useTranslation } from 'react-i18next';
+
+import type { AgentScheduleViewModel } from 
'../model/agent-schedule-view-model';
+import styles from './agent-schedule-view.module.css';
+
+export function AgentScheduleEditor({ controller }: { controller: 
AgentScheduleViewModel }) {
+  const { t } = useTranslation();
+  const editor = controller.editor;
+  if (!editor) return null;
+  const draft = editor.draft;
+  const receiverTypes = selectedReceiverTypes(controller);
+  const templates =
+    receiverTypes.size === 1 ? controller.options.templates.filter(option => 
receiverTypes.has(option.type)) : [];
+  const valid =
+    draft.name.trim() && draft.instruction.trim() && 
draft.cronExpression.trim() && draft.receiverIds.length;
+  return (
+    <Modal
+      open
+      destroyOnHidden
+      title={t(editor.mode === 'create' ? 'aiSchedules.editor.createTitle' : 
'aiSchedules.editor.editTitle')}
+      okText={t('aiSchedules.editor.save')}
+      cancelText={t('common.cancel')}
+      okButtonProps={{ disabled: !valid, loading: controller.busy === 'save' }}
+      onOk={() => void controller.actions.save()}
+      onCancel={controller.actions.closeEditor}
+    >
+      <div className={styles.editor}>
+        <EditorTextFields controller={controller} />
+        <ScheduleField label={t('aiSchedules.editor.receivers')}>
+          <Select
+            mode="multiple"
+            value={draft.receiverIds}
+            options={controller.options.receivers.map(option => ({ value: 
option.id, label: option.name }))}
+            onChange={receiverIds => updateReceivers(controller, receiverIds)}
+          />
+        </ScheduleField>
+        <ScheduleField label={t('aiSchedules.editor.template')} 
hint={t('aiSchedules.editor.templateHint')}>
+          <Select
+            allowClear
+            value={draft.templateId ?? undefined}
+            disabled={receiverTypes.size !== 1}
+            placeholder={t('aiSchedules.editor.defaultTemplate')}
+            options={templates.map(option => ({ value: option.id, label: 
option.name }))}
+            onChange={templateId => controller.actions.updateDraft({ 
templateId: templateId ?? null })}
+          />
+        </ScheduleField>
+        {editor.mode === 'create' ? <EnabledField controller={controller} /> : 
null}
+      </div>
+    </Modal>
+  );
+}
+
+function EditorTextFields({ controller }: { controller: AgentScheduleViewModel 
}) {
+  const { t } = useTranslation();
+  const draft = controller.editor!.draft;
+  return (
+    <>
+      <ScheduleField label={t('aiSchedules.editor.name')}>
+        <Input
+          value={draft.name}
+          maxLength={128}
+          onChange={event => controller.actions.updateDraft({ name: 
event.target.value })}
+        />
+      </ScheduleField>
+      <ScheduleField label={t('aiSchedules.editor.instruction')}>
+        <Input.TextArea
+          value={draft.instruction}
+          rows={5}
+          maxLength={4096}
+          onChange={event => controller.actions.updateDraft({ instruction: 
event.target.value })}
+        />
+      </ScheduleField>
+      <ScheduleField label={t('aiSchedules.editor.cron')} 
hint={t('aiSchedules.editor.cronHint')}>
+        <Input
+          value={draft.cronExpression}
+          maxLength={64}
+          onChange={event => controller.actions.updateDraft({ cronExpression: 
event.target.value })}
+        />
+      </ScheduleField>
+    </>
+  );
+}
+
+function EnabledField({ controller }: { controller: AgentScheduleViewModel }) {
+  const { t } = useTranslation();
+  return (
+    <div className={styles.fieldHeader}>
+      <Typography.Text>{t('aiSchedules.editor.enabled')}</Typography.Text>
+      <Switch
+        checked={controller.editor!.draft.enabled}
+        onChange={enabled => controller.actions.updateDraft({ enabled })}
+      />
+    </div>
+  );
+}
+
+function ScheduleField({ label, hint, children }: { label: string; hint?: 
string; children: React.ReactNode }) {
+  return (
+    <label className={styles.field}>
+      <span className={styles.fieldHeader}>
+        <Typography.Text>{label}</Typography.Text>
+        {hint ? <span className={styles.fieldHint}>{hint}</span> : null}
+      </span>
+      {children}
+    </label>
+  );
+}
+
+function selectedReceiverTypes(controller: AgentScheduleViewModel) {
+  const receiverIds = controller.editor!.draft.receiverIds;
+  return new Set(
+    controller.options.receivers.filter(option => 
receiverIds.includes(option.id)).map(option => option.type)
+  );
+}
+
+function updateReceivers(controller: AgentScheduleViewModel, receiverIds: 
number[]) {
+  const selectedTypes = new Set(
+    controller.options.receivers.filter(receiver => 
receiverIds.includes(receiver.id)).map(receiver => receiver.type)
+  );
+  const selectedTemplate = controller.options.templates.find(
+    template => template.id === controller.editor?.draft.templateId
+  );
+  controller.actions.updateDraft({
+    receiverIds,
+    templateId:
+      selectedTypes.size === 1 && selectedTemplate && 
selectedTypes.has(selectedTemplate.type)
+        ? selectedTemplate.id
+        : null
+  });
+}
diff --git 
a/web-app/src/features/ai-workspace/components/agent-schedule-table.tsx 
b/web-app/src/features/ai-workspace/components/agent-schedule-table.tsx
new file mode 100644
index 0000000000..dbe729a1c3
--- /dev/null
+++ b/web-app/src/features/ai-workspace/components/agent-schedule-table.tsx
@@ -0,0 +1,138 @@
+/* Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0.
+ */
+
+import { Button, Popconfirm, Space, Switch, Table, Typography } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import { useTranslation } from 'react-i18next';
+
+import type { AgentSchedule } from '../model/agent-schedule-contract';
+import type { AgentScheduleViewModel } from 
'../model/agent-schedule-view-model';
+import styles from './agent-schedule-view.module.css';
+
+type Translate = (key: string) => string;
+
+export function AgentScheduleTable({ controller }: { controller: 
AgentScheduleViewModel }) {
+  const { t } = useTranslation();
+  return (
+    <Table
+      rowKey="id"
+      size="small"
+      columns={scheduleColumns(controller, t)}
+      dataSource={controller.list.items}
+      pagination={{
+        current: controller.list.pageIndex + 1,
+        pageSize: controller.list.pageSize,
+        total: controller.list.total,
+        showSizeChanger: true,
+        onChange: (page, size) => void controller.actions.setPage(page - 1, 
size)
+      }}
+    />
+  );
+}
+
+function scheduleColumns(controller: AgentScheduleViewModel, t: Translate): 
ColumnsType<AgentSchedule> {
+  const busy = controller.busy !== null;
+  return [
+    {
+      title: t('aiSchedules.columns.schedule'),
+      render: (_, schedule) => (
+        <span className={styles.scheduleName}>
+          <Typography.Text strong>{schedule.name}</Typography.Text>
+          <small>{schedule.cronExpression}</small>
+        </span>
+      )
+    },
+    {
+      title: t('aiSchedules.columns.instruction'),
+      dataIndex: 'instruction',
+      render: value => <span 
className={styles.scheduleInstruction}>{value}</span>
+    },
+    deliveryColumn(controller, t),
+    {
+      title: t('aiSchedules.columns.nextRun'),
+      dataIndex: 'nextTriggerAt',
+      width: 170,
+      render: (value: number | null) => (
+        <span className={styles.scheduleTime}>{formatTimestamp(value, 
t('aiSchedules.never'))}</span>
+      )
+    },
+    {
+      title: t('aiSchedules.columns.enabled'),
+      width: 90,
+      render: (_, schedule) => (
+        <Switch
+          checked={schedule.enabled}
+          disabled={busy}
+          aria-label={t('aiSchedules.columns.enabled')}
+          onChange={enabled => void controller.actions.toggle(schedule.id, 
enabled)}
+        />
+      )
+    },
+    actionsColumn(controller, t)
+  ];
+}
+
+function deliveryColumn(controller: AgentScheduleViewModel, t: Translate): 
ColumnsType<AgentSchedule>[number] {
+  return {
+    title: t('aiSchedules.columns.delivery'),
+    width: 210,
+    render: (_, schedule) => (
+      <span className={styles.scheduleName}>
+        <span>{receiverNames(controller, schedule)}</span>
+        <small>{templateName(controller, schedule, 
t('aiSchedules.editor.defaultTemplate'))}</small>
+      </span>
+    )
+  };
+}
+
+function actionsColumn(controller: AgentScheduleViewModel, t: Translate): 
ColumnsType<AgentSchedule>[number] {
+  const busy = controller.busy !== null;
+  return {
+    title: t('aiSchedules.columns.actions'),
+    width: 330,
+    render: (_, schedule) => (
+      <Space size="small" wrap>
+        <Button size="small" disabled={busy} onClick={() => void 
controller.actions.run(schedule.id)}>
+          {t('aiSchedules.actions.runNow')}
+        </Button>
+        <Button size="small" disabled={busy} onClick={() => void 
controller.actions.openTranscript(schedule)}>
+          {t('aiSchedules.actions.transcript')}
+        </Button>
+        <Button size="small" disabled={busy} onClick={() => 
controller.actions.openEdit(schedule)}>
+          {t('aiSchedules.actions.edit')}
+        </Button>
+        <Popconfirm
+          title={t('aiSchedules.actions.deleteConfirm')}
+          onConfirm={() => void controller.actions.delete(schedule.id)}
+        >
+          <Button size="small" danger disabled={busy}>
+            {t('aiSchedules.actions.delete')}
+          </Button>
+        </Popconfirm>
+      </Space>
+    )
+  };
+}
+
+function receiverNames(controller: AgentScheduleViewModel, schedule: 
AgentSchedule) {
+  return schedule.receiverIds
+    .map(id => controller.options.receivers.find(receiver => receiver.id === 
id)?.name ?? String(id))
+    .join(', ');
+}
+
+function templateName(controller: AgentScheduleViewModel, schedule: 
AgentSchedule, fallback: string) {
+  if (schedule.templateId === null) return fallback;
+  return (
+    controller.options.templates.find(template => template.id === 
schedule.templateId)?.name ??
+    String(schedule.templateId)
+  );
+}
+
+function formatTimestamp(value: number | null, fallback: string) {
+  return value === null
+    ? fallback
+    : new Intl.DateTimeFormat(undefined, { dateStyle: 'short', timeStyle: 
'short' }).format(value);
+}
diff --git 
a/web-app/src/features/ai-workspace/components/agent-schedule-transcript.tsx 
b/web-app/src/features/ai-workspace/components/agent-schedule-transcript.tsx
new file mode 100644
index 0000000000..57a1f135d7
--- /dev/null
+++ b/web-app/src/features/ai-workspace/components/agent-schedule-transcript.tsx
@@ -0,0 +1,55 @@
+/* Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0.
+ */
+
+import { Alert, Button, Modal, Space, Tag, Typography } from 'antd';
+import { useTranslation } from 'react-i18next';
+
+import type { AgentScheduleViewModel } from 
'../model/agent-schedule-view-model';
+import styles from './agent-schedule-view.module.css';
+
+export function AgentScheduleTranscript({ controller }: { controller: 
AgentScheduleViewModel }) {
+  const { t } = useTranslation();
+  const transcript = controller.transcript;
+  return (
+    <Modal
+      open={transcript.open}
+      footer={null}
+      width={760}
+      title={t('aiSchedules.transcript.title', { name: 
transcript.schedule?.name ?? '' })}
+      onCancel={controller.actions.closeTranscript}
+    >
+      {transcript.kind === 'loading' ? 
<Typography.Text>{t('aiSchedules.transcript.loading')}</Typography.Text> : null}
+      {transcript.kind === 'empty' ? 
<Typography.Text>{t('aiSchedules.transcript.empty')}</Typography.Text> : null}
+      {transcript.kind === 'error' ? (
+        <Alert type="error" showIcon 
message={t('aiSchedules.transcript.unavailable')} />
+      ) : null}
+      {transcript.kind === 'ready' ? <TranscriptEntries 
controller={controller} /> : null}
+    </Modal>
+  );
+}
+
+function TranscriptEntries({ controller }: { controller: 
AgentScheduleViewModel }) {
+  const { t } = useTranslation();
+  const transcript = controller.transcript;
+  return (
+    <div className={styles.transcript}>
+      {transcript.hasEarlier ? (
+        <Button loading={transcript.loadingEarlier} onClick={() => void 
controller.actions.loadEarlierTranscript()}>
+          {t('aiSchedules.transcript.loadEarlier')}
+        </Button>
+      ) : null}
+      {transcript.entries.map(entry => (
+        <article className={styles.transcriptEntry} key={entry.id}>
+          <Space>
+            <Tag>{entry.role}</Tag>
+            <Typography.Text type="secondary">{entry.createdAt ?? 
''}</Typography.Text>
+          </Space>
+          <p>{entry.text || t('aiSchedules.transcript.noText')}</p>
+        </article>
+      ))}
+    </div>
+  );
+}
diff --git 
a/web-app/src/features/ai-workspace/components/agent-schedule-view.module.css 
b/web-app/src/features/ai-workspace/components/agent-schedule-view.module.css
new file mode 100644
index 0000000000..0c93de5d9e
--- /dev/null
+++ 
b/web-app/src/features/ai-workspace/components/agent-schedule-view.module.css
@@ -0,0 +1,76 @@
+/* Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0.
+ */
+
+.toolbar {
+  display: flex;
+  justify-content: flex-end;
+  margin-bottom: var(--hb-space-3);
+}
+
+.scheduleName {
+  display: grid;
+  gap: 2px;
+  min-width: 180px;
+}
+
+.scheduleName small,
+.scheduleTime {
+  color: var(--hb-text-secondary);
+}
+
+.scheduleInstruction {
+  display: -webkit-box;
+  max-width: 420px;
+  overflow: hidden;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 2;
+  line-height: 1.45;
+}
+
+.failure {
+  margin-bottom: var(--hb-space-3);
+}
+
+.editor {
+  display: grid;
+  gap: var(--hb-space-4);
+}
+
+.field {
+  display: grid;
+  gap: var(--hb-space-2);
+}
+
+.fieldHeader {
+  display: flex;
+  justify-content: space-between;
+  gap: var(--hb-space-3);
+}
+
+.fieldHint {
+  color: var(--hb-text-secondary);
+  font-size: 12px;
+}
+
+.transcript {
+  display: grid;
+  gap: var(--hb-space-4);
+  max-height: 60vh;
+  overflow: auto;
+}
+
+.transcriptEntry {
+  display: grid;
+  gap: var(--hb-space-1);
+  padding-left: var(--hb-space-3);
+  border-left: 2px solid var(--hb-border-subtle);
+}
+
+.transcriptEntry p {
+  margin: 0;
+  white-space: pre-wrap;
+  overflow-wrap: anywhere;
+}
diff --git 
a/web-app/src/features/ai-workspace/components/agent-schedule-view.tsx 
b/web-app/src/features/ai-workspace/components/agent-schedule-view.tsx
new file mode 100644
index 0000000000..9b7635b434
--- /dev/null
+++ b/web-app/src/features/ai-workspace/components/agent-schedule-view.tsx
@@ -0,0 +1,65 @@
+/* Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0.
+ */
+
+import { Alert, Button } from 'antd';
+import { useTranslation } from 'react-i18next';
+
+import { OperationalStatePanel } from '@/shared/operational-page';
+
+import type { AgentScheduleViewModel } from 
'../model/agent-schedule-view-model';
+import { AgentScheduleEditor } from './agent-schedule-editor';
+import { AgentScheduleTable } from './agent-schedule-table';
+import { AgentScheduleTranscript } from './agent-schedule-transcript';
+import styles from './agent-schedule-view.module.css';
+
+export function AgentScheduleView({ controller }: { controller: 
AgentScheduleViewModel }) {
+  if (controller.list.kind !== 'ready') {
+    return <ScheduleState controller={controller} />;
+  }
+  return (
+    <>
+      <MutationFailure controller={controller} />
+      <AgentScheduleTable controller={controller} />
+      <AgentScheduleEditor controller={controller} />
+      <AgentScheduleTranscript controller={controller} />
+    </>
+  );
+}
+
+function ScheduleState({ controller }: { controller: AgentScheduleViewModel }) 
{
+  const { t } = useTranslation();
+  if (controller.list.kind === 'loading') {
+    return <OperationalStatePanel kind="loading" 
title={t('aiSchedules.states.loading')} />;
+  }
+  if (controller.list.kind === 'empty') {
+    return (
+      <>
+        <MutationFailure controller={controller} />
+        <OperationalStatePanel
+          kind="empty"
+          title={t('aiSchedules.states.empty')}
+          description={t('aiSchedules.states.emptyDescription')}
+          action={<Button 
onClick={controller.actions.openCreate}>{t('aiSchedules.create')}</Button>}
+        />
+        <AgentScheduleEditor controller={controller} />
+      </>
+    );
+  }
+  return (
+    <OperationalStatePanel
+      kind="unavailable"
+      title={t('aiSchedules.states.unavailable')}
+      action={<Button onClick={() => void 
controller.actions.reload()}>{t('common.retry')}</Button>}
+    />
+  );
+}
+
+function MutationFailure({ controller }: { controller: AgentScheduleViewModel 
}) {
+  const { t } = useTranslation();
+  return controller.mutationFailed ? (
+    <Alert className={styles.failure ?? ''} type="error" showIcon 
message={t('aiSchedules.states.mutationFailed')} />
+  ) : null;
+}
diff --git 
a/web-app/src/features/ai-workspace/components/agent-workspace-session-pane.tsx 
b/web-app/src/features/ai-workspace/components/agent-workspace-session-pane.tsx
index 331095c830..79fe936ca6 100644
--- 
a/web-app/src/features/ai-workspace/components/agent-workspace-session-pane.tsx
+++ 
b/web-app/src/features/ai-workspace/components/agent-workspace-session-pane.tsx
@@ -1,6 +1,6 @@
 /* Licensed to the Apache Software Foundation (ASF) under the Apache License, 
Version 2.0. */
 
-import { SettingOutlined } from '@ant-design/icons';
+import { ScheduleOutlined, SettingOutlined } from '@ant-design/icons';
 import { Button, Typography } from 'antd';
 import { useTranslation } from 'react-i18next';
 
@@ -10,11 +10,13 @@ import styles from './agent-workspace-view.module.css';
 export function AgentWorkspaceSessionPane({
   controller,
   isAdmin,
-  onOpenProviders
+  onOpenProviders,
+  onOpenSchedules
 }: {
   controller: AgentWorkspaceViewModel;
   isAdmin: boolean;
   onOpenProviders: () => void;
+  onOpenSchedules: () => void;
 }) {
   const { t } = useTranslation();
   return (
@@ -41,9 +43,14 @@ export function AgentWorkspaceSessionPane({
         ))}
       </div>
       {isAdmin ? (
-        <Button className={styles.providerButton ?? ''} icon={<SettingOutlined 
/>} onClick={onOpenProviders}>
-          {t('aiWorkspace.providers.action')}
-        </Button>
+        <div className={styles.adminActions}>
+          <Button aria-label={t('aiSchedules.workspaceAction')} 
icon={<ScheduleOutlined />} onClick={onOpenSchedules}>
+            {t('aiSchedules.workspaceAction')}
+          </Button>
+          <Button icon={<SettingOutlined />} onClick={onOpenProviders}>
+            {t('aiWorkspace.providers.action')}
+          </Button>
+        </div>
       ) : null}
     </nav>
   );
diff --git 
a/web-app/src/features/ai-workspace/components/agent-workspace-view.module.css 
b/web-app/src/features/ai-workspace/components/agent-workspace-view.module.css
index 7a4ae1ce24..122f294631 100644
--- 
a/web-app/src/features/ai-workspace/components/agent-workspace-view.module.css
+++ 
b/web-app/src/features/ai-workspace/components/agent-workspace-view.module.css
@@ -75,7 +75,9 @@
 .sessionButton small {
   color: var(--hb-text-secondary);
 }
-.providerButton {
+.adminActions {
+  display: grid;
+  gap: var(--hb-space-2);
   margin: auto var(--hb-space-3) var(--hb-space-3);
 }
 
diff --git 
a/web-app/src/features/ai-workspace/components/agent-workspace-view.test.tsx 
b/web-app/src/features/ai-workspace/components/agent-workspace-view.test.tsx
index 3a697396cc..00d0a817bc 100644
--- a/web-app/src/features/ai-workspace/components/agent-workspace-view.test.tsx
+++ b/web-app/src/features/ai-workspace/components/agent-workspace-view.test.tsx
@@ -17,9 +17,10 @@ describe('AgentWorkspaceView', () => {
 
   it('keeps investigation history, the streamed answer, and exact action 
review in separate regions', () => {
     const controller = fixture();
+    const openSchedules = vi.fn();
     const view = render(
       <I18nextProvider i18n={i18n}>
-        <AgentWorkspaceView controller={controller} isAdmin 
onOpenProviders={vi.fn()} />
+        <AgentWorkspaceView controller={controller} isAdmin 
onOpenProviders={vi.fn()} onOpenSchedules={openSchedules} />
       </I18nextProvider>
     );
 
@@ -32,6 +33,8 @@ describe('AgentWorkspaceView', () => {
     expect(context).toHaveTextContent('monitor.disable');
     expect(within(context).getByRole('button', { name: 'Approve' 
})).toBeInTheDocument();
     expect(within(context).getByRole('button', { name: 'Reject' 
})).toBeInTheDocument();
+    fireEvent.click(screen.getByRole('button', { name: 'Scheduled 
investigations' }));
+    expect(openSchedules).toHaveBeenCalledOnce();
 
     fireEvent.change(screen.getByRole('textbox', { name: 'Investigation 
prompt' }), {
       target: { value: 'Check database waits' }
@@ -42,6 +45,7 @@ describe('AgentWorkspaceView', () => {
           controller={{ ...controller, composer: 'Check database waits' }}
           isAdmin
           onOpenProviders={vi.fn()}
+          onOpenSchedules={openSchedules}
         />
       </I18nextProvider>
     );
diff --git 
a/web-app/src/features/ai-workspace/components/agent-workspace-view.tsx 
b/web-app/src/features/ai-workspace/components/agent-workspace-view.tsx
index acb2d0ec93..5038d2cf94 100644
--- a/web-app/src/features/ai-workspace/components/agent-workspace-view.tsx
+++ b/web-app/src/features/ai-workspace/components/agent-workspace-view.tsx
@@ -9,15 +9,22 @@ import styles from './agent-workspace-view.module.css';
 export function AgentWorkspaceView({
   controller,
   isAdmin,
-  onOpenProviders
+  onOpenProviders,
+  onOpenSchedules
 }: {
   controller: AgentWorkspaceViewModel;
   isAdmin: boolean;
   onOpenProviders: () => void;
+  onOpenSchedules: () => void;
 }) {
   return (
     <div className={styles.workspace}>
-      <AgentWorkspaceSessionPane controller={controller} isAdmin={isAdmin} 
onOpenProviders={onOpenProviders} />
+      <AgentWorkspaceSessionPane
+        controller={controller}
+        isAdmin={isAdmin}
+        onOpenProviders={onOpenProviders}
+        onOpenSchedules={onOpenSchedules}
+      />
       <AgentWorkspaceConversation controller={controller} />
       <AgentWorkspaceContextPane controller={controller} isAdmin={isAdmin} />
     </div>
diff --git 
a/web-app/src/features/ai-workspace/controller/use-agent-schedule-controller.test.tsx
 
b/web-app/src/features/ai-workspace/controller/use-agent-schedule-controller.test.tsx
new file mode 100644
index 0000000000..ca05d6e7cb
--- /dev/null
+++ 
b/web-app/src/features/ai-workspace/controller/use-agent-schedule-controller.test.tsx
@@ -0,0 +1,104 @@
+/* Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0.
+ */
+
+import { act, renderHook, waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const api = vi.hoisted(() => ({
+  createAgentSchedule: vi.fn(),
+  deleteAgentSchedule: vi.fn(),
+  listAgentScheduleOptions: vi.fn(),
+  listAgentSchedules: vi.fn(),
+  listAgentScheduleTranscript: vi.fn(),
+  runAgentSchedule: vi.fn(),
+  toggleAgentSchedule: vi.fn(),
+  updateAgentSchedule: vi.fn()
+}));
+vi.mock('../api/agent-schedule-api', () => api);
+
+import { useAgentScheduleController } from './use-agent-schedule-controller';
+
+describe('useAgentScheduleController', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    api.listAgentSchedules.mockResolvedValue(page([schedule]));
+    api.listAgentScheduleOptions.mockResolvedValue(options);
+    api.createAgentSchedule.mockResolvedValue(schedule);
+    api.listAgentScheduleTranscript.mockResolvedValue({ entries: [transcript], 
pageIndex: 0, hasEarlier: false });
+  });
+
+  it('loads options once with the page and creates a complete schedule draft', 
async () => {
+    const { result } = renderHook(() => useAgentScheduleController());
+    await waitFor(() => expect(result.current.list.kind).toBe('ready'));
+
+    act(() => result.current.actions.openCreate());
+    act(() =>
+      result.current.actions.updateDraft({
+        name: 'Availability review',
+        instruction: 'Review availability.',
+        receiverIds: [11]
+      })
+    );
+    await act(() => result.current.actions.save());
+
+    expect(api.createAgentSchedule).toHaveBeenCalledWith({
+      name: 'Availability review',
+      instruction: 'Review availability.',
+      cronExpression: '0 0 * * * *',
+      enabled: true,
+      receiverIds: [11],
+      templateId: null
+    });
+    expect(api.listAgentSchedules).toHaveBeenCalledTimes(2);
+    expect(result.current.editor).toBeNull();
+  });
+
+  it('loads the fixed transcript without rerunning the schedule', async () => {
+    const { result } = renderHook(() => useAgentScheduleController());
+    await waitFor(() => expect(result.current.list.kind).toBe('ready'));
+
+    await act(() => result.current.actions.openTranscript(schedule));
+
+    expect(api.listAgentScheduleTranscript).toHaveBeenCalledWith(7);
+    expect(api.runAgentSchedule).not.toHaveBeenCalled();
+    expect(result.current.transcript).toMatchObject({ kind: 'ready', entries: 
[transcript] });
+  });
+});
+
+const schedule = {
+  id: 7,
+  name: 'Daily database review',
+  instruction: 'Review database saturation.',
+  cronExpression: '0 0 9 * * *',
+  enabled: true,
+  sessionId: 9,
+  receiverIds: [11],
+  templateId: 21,
+  createdFromSessionUid: null,
+  lastTriggerAt: null,
+  nextTriggerAt: 1_786_678_800_000,
+  creator: 'admin',
+  modifier: 'admin',
+  gmtCreate: null,
+  gmtUpdate: null
+};
+
+const options = {
+  receivers: [{ id: 11, name: 'On-call email', type: 1 }],
+  templates: [{ id: 21, name: 'AI review', type: 1 }]
+};
+
+const transcript = {
+  id: 19,
+  sequence: 2,
+  role: 'assistant' as const,
+  text: 'Database saturation is stable.',
+  createdAt: '2026-08-14T09:00:00'
+};
+
+function page(content: (typeof schedule)[]) {
+  return { content, totalElements: content.length, totalPages: 1, number: 0, 
size: 20 };
+}
diff --git 
a/web-app/src/features/ai-workspace/controller/use-agent-schedule-controller.ts 
b/web-app/src/features/ai-workspace/controller/use-agent-schedule-controller.ts
new file mode 100644
index 0000000000..c9ee5bd58a
--- /dev/null
+++ 
b/web-app/src/features/ai-workspace/controller/use-agent-schedule-controller.ts
@@ -0,0 +1,36 @@
+/* Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0.
+ */
+
+import { useMemo } from 'react';
+
+import type { AgentScheduleViewModel } from 
'../model/agent-schedule-view-model';
+import { useAgentScheduleEditor } from './use-agent-schedule-editor';
+import { useAgentScheduleList } from './use-agent-schedule-list';
+import { useAgentScheduleTranscript } from './use-agent-schedule-transcript';
+
+export function useAgentScheduleController(): AgentScheduleViewModel {
+  const scheduleList = useAgentScheduleList();
+  const scheduleEditor = useAgentScheduleEditor(scheduleList.list, 
scheduleList.load);
+  const scheduleTranscript = 
useAgentScheduleTranscript(scheduleEditor.reportFailure);
+  const actions = useMemo<AgentScheduleViewModel['actions']>(
+    () => ({
+      reload: scheduleList.reload,
+      setPage: scheduleList.load,
+      ...scheduleEditor.actions,
+      ...scheduleTranscript.actions
+    }),
+    [scheduleEditor.actions, scheduleList.load, scheduleList.reload, 
scheduleTranscript.actions]
+  );
+  return {
+    list: scheduleList.list,
+    options: scheduleList.options,
+    editor: scheduleEditor.editor,
+    transcript: scheduleTranscript.transcript,
+    busy: scheduleEditor.busy,
+    mutationFailed: scheduleEditor.mutationFailed,
+    actions
+  };
+}
diff --git 
a/web-app/src/features/ai-workspace/controller/use-agent-schedule-editor.ts 
b/web-app/src/features/ai-workspace/controller/use-agent-schedule-editor.ts
new file mode 100644
index 0000000000..624c9d91c1
--- /dev/null
+++ b/web-app/src/features/ai-workspace/controller/use-agent-schedule-editor.ts
@@ -0,0 +1,107 @@
+/* Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0.
+ */
+
+import { useCallback, useMemo, useState } from 'react';
+
+import {
+  createAgentSchedule,
+  deleteAgentSchedule,
+  runAgentSchedule,
+  toggleAgentSchedule,
+  updateAgentSchedule
+} from '../api/agent-schedule-api';
+import type { AgentSchedule, AgentScheduleDraft } from 
'../model/agent-schedule-contract';
+import type { AgentScheduleEditorState, AgentScheduleListState } from 
'../model/agent-schedule-view-model';
+
+type LoadSchedules = (pageIndex: number, pageSize: number, signal?: 
AbortSignal) => Promise<void>;
+
+export function useAgentScheduleEditor(list: AgentScheduleListState, load: 
LoadSchedules) {
+  const [editor, setEditor] = useState<AgentScheduleEditorState | null>(null);
+  const [busy, setBusy] = useState<string | null>(null);
+  const [mutationFailed, setMutationFailed] = useState(false);
+  const mutate = useScheduleMutation(list, load, setBusy, setMutationFailed);
+  const actions = useMemo(
+    () => ({
+      openCreate: () => setEditor({ mode: 'create' as const, scheduleId: null, 
draft: newDraft() }),
+      openEdit: (schedule: AgentSchedule) => setEditor(editorFor(schedule)),
+      closeEditor: () => setEditor(null),
+      updateDraft: (patch: Partial<AgentScheduleDraft>) =>
+        setEditor(current => (current ? { ...current, draft: { 
...current.draft, ...patch } } : null)),
+      save: async () => {
+        if (!editor) return;
+        const saved = await mutate('save', () =>
+          editor.scheduleId === null
+            ? createAgentSchedule(editor.draft)
+            : updateAgentSchedule(editor.scheduleId, editor.draft)
+        );
+        if (saved) setEditor(null);
+      },
+      toggle: async (scheduleId: number, enabled: boolean) => {
+        await mutate(`toggle:${scheduleId}`, () => 
toggleAgentSchedule(scheduleId, enabled));
+      },
+      run: async (scheduleId: number) => {
+        await mutate(`run:${scheduleId}`, () => runAgentSchedule(scheduleId));
+      },
+      delete: async (scheduleId: number) => {
+        await mutate(`delete:${scheduleId}`, () => 
deleteAgentSchedule(scheduleId));
+      }
+    }),
+    [editor, mutate]
+  );
+  const reportFailure = useCallback(() => setMutationFailed(true), []);
+  return { editor, busy, mutationFailed, reportFailure, actions };
+}
+
+function useScheduleMutation(
+  list: AgentScheduleListState,
+  load: LoadSchedules,
+  setBusy: (value: string | null) => void,
+  setMutationFailed: (value: boolean) => void
+) {
+  return useCallback(
+    async (key: string, action: () => Promise<unknown>) => {
+      setBusy(key);
+      setMutationFailed(false);
+      try {
+        await action();
+        await load(list.pageIndex, list.pageSize);
+        return true;
+      } catch {
+        setMutationFailed(true);
+        return false;
+      } finally {
+        setBusy(null);
+      }
+    },
+    [list.pageIndex, list.pageSize, load, setBusy, setMutationFailed]
+  );
+}
+
+function newDraft(): AgentScheduleDraft {
+  return {
+    name: '',
+    instruction: '',
+    cronExpression: '0 0 * * * *',
+    enabled: true,
+    receiverIds: [],
+    templateId: null
+  };
+}
+
+function editorFor(schedule: AgentSchedule): AgentScheduleEditorState {
+  return {
+    mode: 'edit',
+    scheduleId: schedule.id,
+    draft: {
+      name: schedule.name,
+      instruction: schedule.instruction,
+      cronExpression: schedule.cronExpression,
+      enabled: schedule.enabled,
+      receiverIds: [...schedule.receiverIds],
+      templateId: schedule.templateId
+    }
+  };
+}
diff --git 
a/web-app/src/features/ai-workspace/controller/use-agent-schedule-list.ts 
b/web-app/src/features/ai-workspace/controller/use-agent-schedule-list.ts
new file mode 100644
index 0000000000..cda9f32342
--- /dev/null
+++ b/web-app/src/features/ai-workspace/controller/use-agent-schedule-list.ts
@@ -0,0 +1,58 @@
+/* Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0.
+ */
+
+import { useCallback, useEffect, useState } from 'react';
+
+import { listAgentScheduleOptions, listAgentSchedules } from 
'../api/agent-schedule-api';
+import type { AgentScheduleOptions } from '../model/agent-schedule-contract';
+import type { AgentScheduleListState } from 
'../model/agent-schedule-view-model';
+
+const pageSize = 20;
+const initialList: AgentScheduleListState = {
+  kind: 'loading',
+  items: [],
+  total: 0,
+  pageIndex: 0,
+  pageSize
+};
+
+export function useAgentScheduleList() {
+  const [list, setList] = useState<AgentScheduleListState>(initialList);
+  const [options, setOptions] = useState<AgentScheduleOptions>({ receivers: 
[], templates: [] });
+  const load = useCallback(async (nextPageIndex: number, nextPageSize: number, 
signal?: AbortSignal) => {
+    setList(current => ({ ...current, kind: 'loading', pageIndex: 
nextPageIndex, pageSize: nextPageSize }));
+    try {
+      const [page, nextOptions] = await Promise.all([
+        listAgentSchedules(nextPageIndex, nextPageSize, signal),
+        listAgentScheduleOptions(signal)
+      ]);
+      setOptions(nextOptions);
+      setList({
+        kind: page.content.length === 0 ? 'empty' : 'ready',
+        items: page.content,
+        total: page.totalElements,
+        pageIndex: page.number,
+        pageSize: page.size
+      });
+    } catch {
+      if (signal?.aborted) return;
+      setList({ kind: 'error', items: [], total: 0, pageIndex: nextPageIndex, 
pageSize: nextPageSize });
+    }
+  }, []);
+  useInitialScheduleLoad(load);
+  const reload = useCallback(() => load(list.pageIndex, list.pageSize), 
[list.pageIndex, list.pageSize, load]);
+  return { list, options, load, reload };
+}
+
+function useInitialScheduleLoad(load: (pageIndex: number, pageSize: number, 
signal?: AbortSignal) => Promise<void>) {
+  useEffect(() => {
+    const request = new AbortController();
+    queueMicrotask(() => {
+      if (!request.signal.aborted) void load(0, pageSize, request.signal);
+    });
+    return () => request.abort();
+  }, [load]);
+}
diff --git 
a/web-app/src/features/ai-workspace/controller/use-agent-schedule-transcript.ts 
b/web-app/src/features/ai-workspace/controller/use-agent-schedule-transcript.ts
new file mode 100644
index 0000000000..931c1099bf
--- /dev/null
+++ 
b/web-app/src/features/ai-workspace/controller/use-agent-schedule-transcript.ts
@@ -0,0 +1,82 @@
+/* Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0.
+ */
+
+import { useCallback, useMemo, useState } from 'react';
+
+import { listAgentScheduleTranscript } from '../api/agent-schedule-api';
+import type { AgentSchedule } from '../model/agent-schedule-contract';
+import type { AgentScheduleTranscriptState } from 
'../model/agent-schedule-view-model';
+
+const initialTranscript: AgentScheduleTranscriptState = {
+  open: false,
+  schedule: null,
+  kind: 'idle',
+  entries: [],
+  pageIndex: 0,
+  hasEarlier: false,
+  loadingEarlier: false
+};
+
+export function useAgentScheduleTranscript(reportFailure: () => void) {
+  const [transcript, setTranscript] = 
useState<AgentScheduleTranscriptState>(initialTranscript);
+  const openTranscript = useCallback(async (schedule: AgentSchedule) => {
+    setTranscript({ ...initialTranscript, open: true, schedule, kind: 
'loading' });
+    try {
+      const page = await listAgentScheduleTranscript(schedule.id);
+      setTranscript({
+        open: true,
+        schedule,
+        kind: page.entries.length === 0 ? 'empty' : 'ready',
+        entries: [...page.entries].reverse(),
+        pageIndex: page.pageIndex,
+        hasEarlier: page.hasEarlier,
+        loadingEarlier: false
+      });
+    } catch {
+      setTranscript({ ...initialTranscript, open: true, schedule, kind: 
'error' });
+    }
+  }, []);
+  const loadEarlierTranscript = useLoadEarlierTranscript(transcript, 
setTranscript, reportFailure);
+  const actions = useMemo(
+    () => ({
+      openTranscript,
+      loadEarlierTranscript,
+      closeTranscript: () => setTranscript(initialTranscript)
+    }),
+    [loadEarlierTranscript, openTranscript]
+  );
+  return { transcript, actions };
+}
+
+function useLoadEarlierTranscript(
+  transcript: AgentScheduleTranscriptState,
+  setTranscript: 
React.Dispatch<React.SetStateAction<AgentScheduleTranscriptState>>,
+  reportFailure: () => void
+) {
+  return useCallback(async () => {
+    if (!transcript.schedule || !transcript.hasEarlier || 
transcript.loadingEarlier) return;
+    const scheduleId = transcript.schedule.id;
+    setTranscript(value => ({ ...value, loadingEarlier: true }));
+    try {
+      const page = await listAgentScheduleTranscript(scheduleId, 
transcript.pageIndex + 1);
+      setTranscript(value =>
+        value.schedule?.id === scheduleId
+          ? {
+              ...value,
+              kind: 'ready',
+              entries: [...page.entries].reverse().concat(value.entries),
+              pageIndex: page.pageIndex,
+              hasEarlier: page.hasEarlier,
+              loadingEarlier: false
+            }
+          : value
+      );
+    } catch {
+      setTranscript(value => (value.schedule?.id === scheduleId ? { ...value, 
loadingEarlier: false } : value));
+      reportFailure();
+    }
+  }, [reportFailure, setTranscript, transcript]);
+}
diff --git a/web-app/src/features/ai-workspace/index.ts 
b/web-app/src/features/ai-workspace/index.ts
index 8731cccfc5..4791d06a29 100644
--- a/web-app/src/features/ai-workspace/index.ts
+++ b/web-app/src/features/ai-workspace/index.ts
@@ -1,3 +1,4 @@
 /* Licensed to the Apache Software Foundation (ASF) under the Apache License, 
Version 2.0. */
 
 export { AgentWorkspacePage } from './pages/agent-workspace-page';
+export { AgentSchedulePage } from './pages/agent-schedule-page';
diff --git a/web-app/src/features/ai-workspace/model/agent-schedule-contract.ts 
b/web-app/src/features/ai-workspace/model/agent-schedule-contract.ts
new file mode 100644
index 0000000000..67b69d96ba
--- /dev/null
+++ b/web-app/src/features/ai-workspace/model/agent-schedule-contract.ts
@@ -0,0 +1,52 @@
+/* Licensed to the Apache Software Foundation (ASF) under the Apache License, 
Version 2.0. */
+
+export type AgentSchedule = {
+  id: number;
+  name: string;
+  instruction: string;
+  cronExpression: string;
+  enabled: boolean;
+  sessionId: number | null;
+  receiverIds: number[];
+  templateId: number | null;
+  createdFromSessionUid: string | null;
+  lastTriggerAt: number | null;
+  nextTriggerAt: number | null;
+  creator: string | null;
+  modifier: string | null;
+  gmtCreate: string | null;
+  gmtUpdate: string | null;
+};
+
+export type AgentScheduleDraft = Pick<
+  AgentSchedule,
+  'name' | 'instruction' | 'cronExpression' | 'enabled' | 'receiverIds' | 
'templateId'
+>;
+
+export type AgentScheduleOption = { id: number; name: string; type: number };
+export type AgentScheduleOptions = {
+  receivers: AgentScheduleOption[];
+  templates: AgentScheduleOption[];
+};
+
+export type AgentScheduleTranscriptEntry = {
+  id: number;
+  sequence: number;
+  role: 'user' | 'assistant';
+  text: string;
+  createdAt: string | null;
+};
+
+export type AgentScheduleTranscriptPage = {
+  entries: AgentScheduleTranscriptEntry[];
+  pageIndex: number;
+  hasEarlier: boolean;
+};
+
+export type AgentSchedulePage = {
+  content: AgentSchedule[];
+  totalElements: number;
+  totalPages: number;
+  number: number;
+  size: number;
+};
diff --git 
a/web-app/src/features/ai-workspace/model/agent-schedule-view-model.ts 
b/web-app/src/features/ai-workspace/model/agent-schedule-view-model.ts
new file mode 100644
index 0000000000..1c24bc3ae0
--- /dev/null
+++ b/web-app/src/features/ai-workspace/model/agent-schedule-view-model.ts
@@ -0,0 +1,60 @@
+/* Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0.
+ */
+
+import type {
+  AgentSchedule,
+  AgentScheduleDraft,
+  AgentScheduleOptions,
+  AgentScheduleTranscriptEntry
+} from './agent-schedule-contract';
+
+export type AgentScheduleListState = {
+  kind: 'loading' | 'ready' | 'empty' | 'error';
+  items: AgentSchedule[];
+  total: number;
+  pageIndex: number;
+  pageSize: number;
+};
+
+export type AgentScheduleEditorState = {
+  mode: 'create' | 'edit';
+  scheduleId: number | null;
+  draft: AgentScheduleDraft;
+};
+
+export type AgentScheduleTranscriptState = {
+  open: boolean;
+  schedule: AgentSchedule | null;
+  kind: 'idle' | 'loading' | 'ready' | 'empty' | 'error';
+  entries: AgentScheduleTranscriptEntry[];
+  pageIndex: number;
+  hasEarlier: boolean;
+  loadingEarlier: boolean;
+};
+
+export type AgentScheduleViewModel = {
+  list: AgentScheduleListState;
+  options: AgentScheduleOptions;
+  editor: AgentScheduleEditorState | null;
+  transcript: AgentScheduleTranscriptState;
+  busy: string | null;
+  mutationFailed: boolean;
+  actions: {
+    reload: () => Promise<void>;
+    setPage: (pageIndex: number, pageSize: number) => Promise<void>;
+    openCreate: () => void;
+    openEdit: (schedule: AgentSchedule) => void;
+    closeEditor: () => void;
+    updateDraft: (patch: Partial<AgentScheduleDraft>) => void;
+    save: () => Promise<void>;
+    toggle: (scheduleId: number, enabled: boolean) => Promise<void>;
+    run: (scheduleId: number) => Promise<void>;
+    delete: (scheduleId: number) => Promise<void>;
+    openTranscript: (schedule: AgentSchedule) => Promise<void>;
+    loadEarlierTranscript: () => Promise<void>;
+    closeTranscript: () => void;
+  };
+};
diff --git 
a/web-app/src/features/ai-workspace/pages/agent-schedule-page.test.tsx 
b/web-app/src/features/ai-workspace/pages/agent-schedule-page.test.tsx
new file mode 100644
index 0000000000..762f64d60d
--- /dev/null
+++ b/web-app/src/features/ai-workspace/pages/agent-schedule-page.test.tsx
@@ -0,0 +1,167 @@
+/* Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0.
+ */
+
+import { App } from 'antd';
+import { cleanup, fireEvent, render, screen, within } from 
'@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const controller = vi.hoisted(() => ({ value: scheduleController() }));
+vi.mock('../controller/use-agent-schedule-controller', () => ({
+  useAgentScheduleController: () => controller.value
+}));
+vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => 
key }) }));
+
+import { AgentSchedulePage } from './agent-schedule-page';
+
+describe('AgentSchedulePage', () => {
+  beforeEach(() => {
+    controller.value = scheduleController();
+  });
+  afterEach(cleanup);
+
+  it('renders the system schedule and delegates every lifecycle action', () => 
{
+    renderPage();
+
+    expect(screen.getByRole('heading', { name: 'aiSchedules.title' 
})).toBeInTheDocument();
+    const row = screen.getByText('Daily database review').closest('tr');
+    expect(row).not.toBeNull();
+    fireEvent.click(within(row!).getByRole('switch'));
+    fireEvent.click(within(row!).getByRole('button', { name: 
'aiSchedules.actions.runNow' }));
+    fireEvent.click(within(row!).getByRole('button', { name: 
'aiSchedules.actions.transcript' }));
+    fireEvent.click(within(row!).getByRole('button', { name: 
'aiSchedules.actions.edit' }));
+    fireEvent.click(within(row!).getByRole('button', { name: 
'aiSchedules.actions.delete' }));
+    fireEvent.click(screen.getByRole('button', { name: 'OK' }));
+
+    expect(controller.value.actions.toggle).toHaveBeenCalledWith(7, false);
+    expect(controller.value.actions.run).toHaveBeenCalledWith(7);
+    
expect(controller.value.actions.openTranscript).toHaveBeenCalledWith(controller.value.list.items[0]);
+    
expect(controller.value.actions.openEdit).toHaveBeenCalledWith(controller.value.list.items[0]);
+    expect(controller.value.actions.delete).toHaveBeenCalledWith(7);
+  });
+
+  it('shows the complete editor and delegates the owned draft', () => {
+    controller.value = scheduleController({
+      editor: {
+        mode: 'create',
+        scheduleId: null,
+        draft: {
+          name: 'Availability review',
+          instruction: 'Review production availability and notify the on-call 
receiver.',
+          cronExpression: '0 0 9 * * *',
+          enabled: true,
+          receiverIds: [11],
+          templateId: null
+        }
+      }
+    });
+    renderPage();
+
+    const dialog = screen.getByRole('dialog');
+    expect(within(dialog).getByDisplayValue('Availability 
review')).toBeInTheDocument();
+    expect(within(dialog).getByDisplayValue('0 0 9 * * 
*')).toBeInTheDocument();
+    expect(within(dialog).getAllByRole('combobox')).toHaveLength(2);
+    fireEvent.click(within(dialog).getByRole('button', { name: 
'aiSchedules.editor.save' }));
+
+    expect(controller.value.actions.save).toHaveBeenCalledOnce();
+  });
+
+  it('keeps loading, empty, and unavailable states distinct', () => {
+    controller.value = scheduleController({
+      list: { kind: 'loading', items: [], total: 0, pageIndex: 0, pageSize: 20 
}
+    });
+    const view = renderPage();
+    
expect(document.querySelector('[data-state="loading"]')).toHaveTextContent('aiSchedules.states.loading');
+
+    controller.value = scheduleController({ list: { kind: 'empty', items: [], 
total: 0, pageIndex: 0, pageSize: 20 } });
+    view.rerender(shell());
+    
expect(document.querySelector('[data-state="empty"]')).toHaveTextContent('aiSchedules.states.empty');
+
+    controller.value = scheduleController({ list: { kind: 'error', items: [], 
total: 0, pageIndex: 0, pageSize: 20 } });
+    view.rerender(shell());
+    
expect(document.querySelector('[data-state="unavailable"]')).toHaveTextContent('aiSchedules.states.unavailable');
+  });
+
+  it('keeps a failed first create visible while the schedule list is empty', 
() => {
+    controller.value = scheduleController({
+      list: { kind: 'empty', items: [], total: 0, pageIndex: 0, pageSize: 20 },
+      mutationFailed: true
+    });
+
+    renderPage();
+
+    
expect(screen.getByText('aiSchedules.states.mutationFailed')).toBeInTheDocument();
+  });
+});
+
+function renderPage() {
+  return render(shell());
+}
+
+function shell() {
+  return (
+    <MemoryRouter>
+      <App>
+        <AgentSchedulePage />
+      </App>
+    </MemoryRouter>
+  );
+}
+
+function scheduleController(overrides: Record<string, unknown> = {}) {
+  const schedule = {
+    id: 7,
+    name: 'Daily database review',
+    instruction: 'Review database saturation.',
+    cronExpression: '0 0 9 * * *',
+    enabled: true,
+    sessionId: 9,
+    receiverIds: [11],
+    templateId: 21,
+    createdFromSessionUid: null,
+    lastTriggerAt: null,
+    nextTriggerAt: 1_786_678_800_000,
+    creator: 'admin',
+    modifier: 'admin',
+    gmtCreate: null,
+    gmtUpdate: null
+  };
+  return {
+    list: { kind: 'ready', items: [schedule], total: 1, pageIndex: 0, 
pageSize: 20 },
+    options: {
+      receivers: [{ id: 11, name: 'On-call email', type: 1 }],
+      templates: [{ id: 21, name: 'AI review', type: 1 }]
+    },
+    editor: null,
+    transcript: {
+      open: false,
+      schedule: null,
+      kind: 'idle',
+      entries: [],
+      pageIndex: 0,
+      hasEarlier: false,
+      loadingEarlier: false
+    },
+    busy: null,
+    mutationFailed: false,
+    actions: {
+      reload: vi.fn(),
+      setPage: vi.fn(),
+      openCreate: vi.fn(),
+      openEdit: vi.fn(),
+      closeEditor: vi.fn(),
+      updateDraft: vi.fn(),
+      save: vi.fn(),
+      toggle: vi.fn(),
+      run: vi.fn(),
+      delete: vi.fn(),
+      openTranscript: vi.fn(),
+      loadEarlierTranscript: vi.fn(),
+      closeTranscript: vi.fn()
+    },
+    ...overrides
+  };
+}
diff --git a/web-app/src/features/ai-workspace/pages/agent-schedule-page.tsx 
b/web-app/src/features/ai-workspace/pages/agent-schedule-page.tsx
new file mode 100644
index 0000000000..e490ed5833
--- /dev/null
+++ b/web-app/src/features/ai-workspace/pages/agent-schedule-page.tsx
@@ -0,0 +1,43 @@
+/* Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0.
+ */
+
+import { Button, Space } from 'antd';
+import { useTranslation } from 'react-i18next';
+import { useNavigate } from 'react-router-dom';
+
+import { applicationRoutePaths } from '@/shared/navigation/app-paths';
+import { OperationalPage, OperationalPageHeader, OperationalResultRegion } 
from '@/shared/operational-page';
+
+import { AgentScheduleView } from '../components/agent-schedule-view';
+import { useAgentScheduleController } from 
'../controller/use-agent-schedule-controller';
+
+export function AgentSchedulePage() {
+  const { t } = useTranslation();
+  const navigate = useNavigate();
+  const controller = useAgentScheduleController();
+  return (
+    <OperationalPage>
+      <OperationalPageHeader
+        title={t('aiSchedules.title')}
+        description={t('aiSchedules.description')}
+        actions={
+          <Space>
+            <Button onClick={() => void 
navigate(applicationRoutePaths.aiWorkspace)}>{t('common.back')}</Button>
+            <Button disabled={controller.busy !== null} onClick={() => void 
controller.actions.reload()}>
+              {t('common.refresh')}
+            </Button>
+            <Button type="primary" disabled={controller.busy !== null} 
onClick={controller.actions.openCreate}>
+              {t('aiSchedules.create')}
+            </Button>
+          </Space>
+        }
+      />
+      <OperationalResultRegion>
+        <AgentScheduleView controller={controller} />
+      </OperationalResultRegion>
+    </OperationalPage>
+  );
+}
diff --git a/web-app/src/features/ai-workspace/pages/agent-workspace-page.tsx 
b/web-app/src/features/ai-workspace/pages/agent-workspace-page.tsx
index d7c726ab76..380c15adc2 100644
--- a/web-app/src/features/ai-workspace/pages/agent-workspace-page.tsx
+++ b/web-app/src/features/ai-workspace/pages/agent-workspace-page.tsx
@@ -3,8 +3,11 @@
 import { AgentProviderDialog } from '../components/agent-provider-dialog';
 import { AgentWorkspaceView } from '../components/agent-workspace-view';
 import { useAgentWorkspacePageController } from 
'../controller/use-agent-workspace-page-controller';
+import { applicationRoutePaths } from '@/shared/navigation/app-paths';
+import { useNavigate } from 'react-router-dom';
 
 export function AgentWorkspacePage() {
+  const navigate = useNavigate();
   const controller = useAgentWorkspacePageController();
   return (
     <>
@@ -12,6 +15,7 @@ export function AgentWorkspacePage() {
         controller={controller.workspace}
         isAdmin={controller.isAdmin}
         onOpenProviders={controller.openProviders}
+        onOpenSchedules={() => void 
navigate(applicationRoutePaths.aiSchedules)}
       />
       {controller.isAdmin ? (
         <AgentProviderDialog
diff --git a/web-app/src/shared/navigation/app-paths.ts 
b/web-app/src/shared/navigation/app-paths.ts
index d68a71a628..4fa0453cbf 100644
--- a/web-app/src/shared/navigation/app-paths.ts
+++ b/web-app/src/shared/navigation/app-paths.ts
@@ -26,6 +26,7 @@ const entityListPath = '/entities';
 export const applicationRoutePaths = {
   dashboard: defaultAuthenticatedPath,
   aiWorkspace: '/ai',
+  aiSchedules: '/ai/schedules',
   instrumentation: '/observability/integration',
   topology: '/topology',
   explore: '/explore',


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to