@agentprojectcontext/apx 1.78.0 → 1.80.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/package.json +1 -1
  2. package/src/core/agent/run-agent.js +46 -6
  3. package/src/core/agent/super-agent.js +3 -0
  4. package/src/core/agent/tool-summary.js +65 -0
  5. package/src/core/agent/tools/handlers/list-commitments.js +80 -0
  6. package/src/core/agent/tools/handlers/list-tasks.js +66 -27
  7. package/src/core/agent/tools/handlers/record-commitment.js +68 -0
  8. package/src/core/agent/tools/handlers/send-telegram.js +68 -2
  9. package/src/core/agent/tools/names.js +6 -0
  10. package/src/core/agent/tools/registry.js +9 -0
  11. package/src/core/agent/tools/tool-call-parser.js +123 -6
  12. package/src/core/channels/telegram/ask-callbacks.js +121 -6
  13. package/src/core/channels/telegram/dispatch.js +21 -4
  14. package/src/core/channels/telegram/inbound/file.js +108 -0
  15. package/src/core/channels/telegram/inbound/photo.js +42 -11
  16. package/src/core/channels/telegram/media.js +31 -3
  17. package/src/core/channels/telegram/reply.js +32 -5
  18. package/src/core/config/paths.js +3 -0
  19. package/src/core/config/redact.js +22 -0
  20. package/src/core/daemon/service.js +238 -0
  21. package/src/core/engines/gemini.js +343 -63
  22. package/src/core/engines/openai-compatible.js +21 -2
  23. package/src/core/memory/consolidate.js +225 -0
  24. package/src/core/nudge/index.js +192 -0
  25. package/src/core/nudge/policy.js +143 -0
  26. package/src/core/nudge/store.js +141 -0
  27. package/src/core/profiles/bundled/secretary/PROFILE.md +8 -9
  28. package/src/core/profiles/bundled/secretary/config.schema.json +33 -3
  29. package/src/core/profiles/bundled/secretary/routines/day-close.json +7 -3
  30. package/src/core/profiles/bundled/secretary/routines/day-open.json +7 -3
  31. package/src/core/profiles/bundled/secretary/routines/watch.json +13 -0
  32. package/src/core/routines/runner.js +102 -3
  33. package/src/core/routines/signals.js +270 -0
  34. package/src/core/stores/commitments.js +331 -0
  35. package/src/core/stores/messages.js +10 -1
  36. package/src/core/stores/routines.js +17 -3
  37. package/src/core/util/thinking.js +51 -0
  38. package/src/host/daemon/api/commitments.js +135 -0
  39. package/src/host/daemon/api/nudges.js +112 -0
  40. package/src/host/daemon/api/routines.js +24 -0
  41. package/src/host/daemon/api/self-memory.js +50 -0
  42. package/src/host/daemon/api/telegram.js +42 -4
  43. package/src/host/daemon/api/voice.js +3 -1
  44. package/src/host/daemon/api.js +6 -0
  45. package/src/host/daemon/callback-reconciler.js +16 -0
  46. package/src/host/daemon/plugins/desktop/index.js +7 -1
  47. package/src/host/daemon/plugins/telegram/index.js +7 -2
  48. package/src/host/daemon/wakeup.js +17 -3
  49. package/src/interfaces/cli/commands/commitment.js +154 -0
  50. package/src/interfaces/cli/commands/daemon.js +57 -0
  51. package/src/interfaces/cli/commands/memory.js +73 -0
  52. package/src/interfaces/cli/commands/nudge.js +130 -0
  53. package/src/interfaces/cli/help/index.js +2 -2
  54. package/src/interfaces/cli/routes/commitment.js +19 -0
  55. package/src/interfaces/cli/routes/daemon.js +7 -1
  56. package/src/interfaces/cli/routes/index.js +4 -0
  57. package/src/interfaces/cli/routes/memory.js +10 -2
  58. package/src/interfaces/cli/routes/nudge.js +17 -0
  59. package/src/interfaces/web/dist/assets/index-CvEoGtTf.js +849 -0
  60. package/src/interfaces/web/dist/assets/index-CvEoGtTf.js.map +1 -0
  61. package/src/interfaces/web/dist/assets/index-DzBBXFaO.css +1 -0
  62. package/src/interfaces/web/dist/index.html +2 -2
  63. package/src/interfaces/web/package-lock.json +11 -10
  64. package/src/interfaces/web/src/components/Section.tsx +18 -3
  65. package/src/interfaces/web/src/components/chat/MessageBubble.tsx +13 -0
  66. package/src/interfaces/web/src/components/cron/CronPicker.tsx +196 -0
  67. package/src/interfaces/web/src/components/inbox/InboxList.tsx +145 -0
  68. package/src/interfaces/web/src/components/memory/MemoryBrowser.tsx +34 -4
  69. package/src/interfaces/web/src/components/routines/RoutineDetail.tsx +16 -4
  70. package/src/interfaces/web/src/components/routines/RoutineEditor.tsx +12 -2
  71. package/src/interfaces/web/src/components/routines/shared.ts +14 -5
  72. package/src/interfaces/web/src/components/settings/NudgePanel.tsx +183 -0
  73. package/src/interfaces/web/src/components/settings/ProfilePanel.tsx +36 -12
  74. package/src/interfaces/web/src/components/ui/filter-chips.tsx +47 -0
  75. package/src/interfaces/web/src/components/ui.tsx +1 -0
  76. package/src/interfaces/web/src/constants/index.ts +1 -0
  77. package/src/interfaces/web/src/hooks/useChat.ts +5 -1
  78. package/src/interfaces/web/src/hooks/useNudges.ts +38 -0
  79. package/src/interfaces/web/src/i18n/en.ts +127 -0
  80. package/src/interfaces/web/src/i18n/es.ts +127 -0
  81. package/src/interfaces/web/src/lib/api/commitments.ts +57 -0
  82. package/src/interfaces/web/src/lib/api/notebook.ts +23 -0
  83. package/src/interfaces/web/src/lib/api/nudges.ts +53 -0
  84. package/src/interfaces/web/src/lib/cron.ts +196 -0
  85. package/src/interfaces/web/src/lib/when.ts +32 -0
  86. package/src/interfaces/web/src/screens/InboxScreen.tsx +107 -77
  87. package/src/interfaces/web/src/screens/ProjectScreen.tsx +5 -2
  88. package/src/interfaces/web/src/screens/SettingsScreen.tsx +17 -3
  89. package/src/interfaces/web/src/screens/base/CommitmentsTab.tsx +239 -0
  90. package/src/interfaces/web/src/screens/base/GlobalTasksTab.tsx +102 -19
  91. package/src/interfaces/web/src/screens/base/LogsTab.tsx +15 -0
  92. package/src/interfaces/web/src/screens/project/ChatTab.tsx +21 -3
  93. package/src/interfaces/web/src/screens/project/RoutinesTab.tsx +13 -11
  94. package/src/interfaces/web/src/types/daemon.ts +10 -1
  95. package/src/interfaces/web/dist/assets/index-CBR_-QyA.js +0 -824
  96. package/src/interfaces/web/dist/assets/index-CBR_-QyA.js.map +0 -1
  97. package/src/interfaces/web/dist/assets/index-D_EJEA1n.css +0 -1
@@ -1,103 +1,133 @@
1
+ import { useEffect, useState } from "react";
1
2
  import { useNavigate } from "react-router-dom";
2
- import { Section } from "../components/Section";
3
- import { Badge, Button, Empty, Loading } from "../components/ui";
3
+ import { ArrowUpRight, EyeOff, Eye } from "lucide-react";
4
+ import { Button, Loading } from "../components/ui";
5
+ import { Tip } from "../components/ui/tip";
6
+ import { InboxList, rowKey } from "../components/inbox/InboxList";
7
+ import { ChatTab } from "./project/ChatTab";
4
8
  import { useInbox } from "../hooks/useInbox";
5
9
  import type { InboxRow } from "../lib/api/inbox";
10
+ import type { ChatKey } from "../components/chat/ChatList";
6
11
  import { t } from "../i18n";
7
- import { useState } from "react";
8
12
 
9
13
  /**
10
14
  * The agent inbox — every agent as a conversation, most recent first.
11
15
  *
12
16
  * A SECOND AXIS over the same data, not a replacement for project navigation:
13
17
  * this is the conversational way in, the project rail is the structural one.
14
- * Clicking a row opens that agent's existing chat rather than re-implementing
15
- * one here the thread UI already exists and must not be forked.
18
+ *
19
+ * Two panes, like any messaging app: pick on the left, read and reply on the
20
+ * right. The list used to fill the width and NAVIGATE AWAY on click, so
21
+ * reading one conversation meant losing the list — which defeats the point of
22
+ * having an inbox at all. The right pane embeds the real chat surface rather
23
+ * than a read-only rendering of it: if you can see what an agent said, you can
24
+ * answer it there and then.
16
25
  */
17
26
  export function InboxScreen() {
18
27
  const navigate = useNavigate();
19
28
  const [includeEmpty, setIncludeEmpty] = useState(false);
20
29
  const { rows, isLoading } = useInbox(includeEmpty);
30
+ const [selected, setSelected] = useState<InboxRow | null>(null);
31
+
32
+ // Open the most recent conversation on arrival. An inbox that lands on an
33
+ // empty pane makes you click twice to see the thing you came for.
34
+ useEffect(() => {
35
+ if (!selected && rows.length) setSelected(rows[0]);
36
+ }, [rows, selected]);
37
+
38
+ if (isLoading) return <Loading />;
39
+
40
+ const pid = selected ? String(selected.project_id ?? 0) : null;
41
+ const title = selected ? (selected.agent_name || selected.agent_slug) : "";
21
42
 
22
- const open = (row: InboxRow) => {
43
+ /**
44
+ * Which conversation the chat pane should open.
45
+ *
46
+ * The super-agent has no per-agent conversation files — it talks on channels,
47
+ * and its history is the cross-channel ledger — so its row addresses a
48
+ * THREAD (channel + date). A project agent addresses a conversation. With
49
+ * neither, fall through to a live session rather than showing nothing.
50
+ */
51
+ const selectionFor = (row: InboxRow): ChatKey | undefined => {
23
52
  if (row.kind === "super_agent") {
24
- navigate("/p/0/chat");
25
- return;
53
+ return row.channel && row.conversation_id
54
+ ? { kind: "thread", channel: row.channel, threadId: row.conversation_id }
55
+ : undefined;
26
56
  }
27
- navigate(`/p/${row.project_id}/agents/${encodeURIComponent(row.agent_slug)}`);
57
+ return row.conversation_id
58
+ ? { kind: "conv", agentSlug: row.agent_slug, convId: row.conversation_id }
59
+ : { kind: "live", agentSlug: row.agent_slug };
28
60
  };
29
61
 
30
62
  return (
31
- <Section
32
- fullHeight
33
- title={t("inbox.title")}
34
- description={t("inbox.subtitle")}
35
- action={
36
- <Button size="sm" variant={includeEmpty ? "primary" : "ghost"} onClick={() => setIncludeEmpty((v) => !v)}>
37
- {t("inbox.show_quiet")}
38
- </Button>
39
- }
40
- >
41
- {isLoading ? <Loading /> : null}
42
- {!isLoading && rows.length === 0 ? <Empty>{t("inbox.empty")}</Empty> : null}
63
+ <div className="flex h-full min-h-0 flex-col gap-3" data-testid="inbox-screen">
64
+ <div className="flex shrink-0 items-baseline justify-between gap-3">
65
+ <div>
66
+ <h1 className="text-lg font-semibold">{t("inbox.title")}</h1>
67
+ <p className="text-xs text-muted-fg">{t("inbox.subtitle")}</p>
68
+ </div>
69
+ <Tip content={includeEmpty ? t("inbox.hide_quiet") : t("inbox.show_quiet")}>
70
+ <Button size="sm" variant={includeEmpty ? "primary" : "ghost"} onClick={() => setIncludeEmpty((v) => !v)}>
71
+ {includeEmpty ? <EyeOff size={14} /> : <Eye size={14} />}
72
+ <span className="hidden sm:inline">{t("inbox.show_quiet")}</span>
73
+ </Button>
74
+ </Tip>
75
+ </div>
43
76
 
44
- <ul className="space-y-2" data-testid="inbox-list">
45
- {rows.map((row) => (
46
- <li key={`${row.project_id ?? "global"}-${row.agent_slug}`}>
47
- <button
48
- type="button"
49
- data-testid={`inbox-row-${row.agent_slug}`}
50
- onClick={() => open(row)}
51
- className={`flex w-full items-start gap-3 rounded-md border px-3 py-2 text-left transition ${
52
- row.pinned
53
- ? "border-primary/60 bg-primary/5 hover:bg-primary/10"
54
- : "border-border bg-muted/30 hover:bg-muted/50"
55
- }`}
56
- >
57
- <span className="mt-0.5 text-lg leading-none">{row.agent_emoji || "🤖"}</span>
77
+ <div className="flex min-h-0 flex-1 overflow-hidden rounded-xl border border-border bg-card/40">
78
+ <InboxList
79
+ rows={rows}
80
+ selectedKey={selected ? rowKey(selected) : null}
81
+ onSelect={setSelected}
82
+ />
58
83
 
59
- <span className="min-w-0 flex-1">
60
- <span className="flex min-w-0 items-baseline gap-2">
61
- <span className="truncate font-medium">{row.agent_name || row.agent_slug}</span>
62
- {row.pinned ? <Badge tone="info">{t("inbox.pinned")}</Badge> : null}
63
- {/* On a phone the timestamp rides with the name; there is no
64
- room for a third column. */}
65
- <span className="ml-auto shrink-0 text-xs opacity-50 sm:hidden">
66
- {shortTime(row.last_activity_at)}
67
- </span>
68
- </span>
84
+ <section className="flex min-w-0 flex-1 flex-col">
85
+ {!selected ? (
86
+ <div className="flex h-full items-center justify-center p-8">
87
+ <p className="text-sm text-muted-fg">{t("inbox.empty")}</p>
88
+ </div>
89
+ ) : (
90
+ <>
91
+ <header className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-4 py-2.5">
92
+ <div className="min-w-0">
93
+ <h2 className="truncate text-sm font-semibold">{title}</h2>
94
+ <p className="truncate text-[11px] text-muted-fg">
95
+ {selected.project_name || t("inbox.super_agent_scope")}
96
+ {selected.channel ? ` · ${selected.channel}` : ""}
97
+ </p>
98
+ </div>
99
+ {/* The structural way out. The inbox is a second axis over the
100
+ same data, so getting from a conversation to its project
101
+ must always be one click. */}
102
+ <Button
103
+ size="sm"
104
+ variant="ghost"
105
+ onClick={() =>
106
+ navigate(
107
+ selected.kind === "super_agent"
108
+ ? "/p/0/chat"
109
+ : `/p/${selected.project_id}/agents/${encodeURIComponent(selected.agent_slug)}`,
110
+ )}
111
+ >
112
+ {t("inbox.open_in_project")} <ArrowUpRight size={13} />
113
+ </Button>
114
+ </header>
69
115
 
70
- <span className="mt-0.5 flex flex-wrap items-center gap-x-2 text-xs opacity-55">
71
- {row.project_name ? <span className="truncate">{row.project_name}</span> : null}
72
- {row.channel ? <span className="opacity-70">· {row.channel}</span> : null}
73
- </span>
74
-
75
- {/* The agent's own last line. Echoing the user's prompt back
76
- would tell them nothing they do not already know. Two lines
77
- on a phone, one on a wide screen. */}
78
- <span className="mt-1 block line-clamp-2 text-sm leading-snug opacity-75 sm:line-clamp-1">
79
- {row.preview || t("inbox.no_reply_yet")}
80
- </span>
81
- </span>
82
-
83
- <span className="hidden shrink-0 text-xs opacity-50 sm:block">
84
- {shortTime(row.last_activity_at)}
85
- </span>
86
- </button>
87
- </li>
88
- ))}
89
- </ul>
90
- </Section>
116
+ <div className="min-h-0 flex-1">
117
+ {/* Remounted per selection: the chat surface holds its own
118
+ session state, and carrying one agent's stream into another
119
+ agent's pane would be worse than a moment's reload. */}
120
+ <ChatTab
121
+ key={rowKey(selected)}
122
+ pid={pid as string}
123
+ hideSidebar
124
+ initialSelection={selectionFor(selected)}
125
+ />
126
+ </div>
127
+ </>
128
+ )}
129
+ </section>
130
+ </div>
131
+ </div>
91
132
  );
92
133
  }
93
-
94
- /** Today → time of day; older → date. Same idea as a messaging app. */
95
- function shortTime(iso: string): string {
96
- if (!iso) return "";
97
- const d = new Date(iso);
98
- if (Number.isNaN(d.getTime())) return "";
99
- const sameDay = new Date().toDateString() === d.toDateString();
100
- return sameDay
101
- ? d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
102
- : d.toLocaleDateString();
103
- }
@@ -5,7 +5,7 @@ import {
5
5
  Bot, Heart, Zap, Puzzle, Settings,
6
6
  MessagesSquare, KeyRound,
7
7
  LayoutDashboard, Boxes, Cpu, ScrollText, History, Brain, FileCode2, Cable,
8
- Building2, FileText, FolderTree, Sparkles,
8
+ Building2, FileText, FolderTree, Sparkles, Handshake,
9
9
  } from "lucide-react";
10
10
  import { useNavCollapse, type TabSection } from "../components/common/TabNav";
11
11
  import { TabLayout } from "../components/common/TabLayout";
@@ -23,6 +23,7 @@ import { ModelsTab } from "./base/ModelsTab";
23
23
  import { AgentDefaultsTab } from "./base/AgentDefaultsTab";
24
24
  import { SessionsTab } from "./base/SessionsTab";
25
25
  import { GlobalTasksTab } from "./base/GlobalTasksTab";
26
+ import { CommitmentsTab } from "./base/CommitmentsTab";
26
27
  import { ConfigTab } from "./project/ConfigTab";
27
28
  import { AgentsTab } from "./project/AgentsTab";
28
29
  import { RoutinesTab } from "./project/RoutinesTab";
@@ -42,7 +43,7 @@ import { SkillsTab } from "./project/SkillsTab";
42
43
 
43
44
  type NavKey =
44
45
  | "" | "chat" | "config" | "telegram"
45
- | "agents" | "routines" | "tasks" | "mcps" | "integrations" | "vars" | "logs" | "memories" | "artifacts"
46
+ | "agents" | "routines" | "tasks" | "commitments" | "mcps" | "integrations" | "vars" | "logs" | "memories" | "artifacts"
46
47
  | "structure" | "docs" | "files" | "skills" | "sessions";
47
48
 
48
49
  export function ProjectScreen() {
@@ -115,6 +116,7 @@ export function ProjectScreen() {
115
116
  items: [
116
117
  { key: "routines", label: t("project.nav.routines"), icon: Heart },
117
118
  { key: "tasks", label: t("project.nav.tasks"), icon: Zap },
119
+ { key: "commitments", label: t("project.nav.commitments"), icon: Handshake },
118
120
  { key: "mcps", label: t("project.nav.mcps"), icon: Puzzle },
119
121
  { key: "integrations", label: "Integrations", icon: Cable },
120
122
  { key: "vars", label: t("project.nav.vars"), icon: KeyRound },
@@ -184,6 +186,7 @@ export function ProjectScreen() {
184
186
  <Route path="skills" element={<SkillsTab pid={pid} />} />
185
187
  <Route path="routines" element={<RoutinesTab pid={pid} />} />
186
188
  <Route path="tasks" element={isBase ? <GlobalTasksTab /> : <TasksTab pid={pid} />} />
189
+ <Route path="commitments" element={<CommitmentsTab pid={isBase ? undefined : pid} />} />
187
190
  <Route path="mcps" element={<McpsTab pid={pid} />} />
188
191
  <Route path="integrations" element={<IntegrationsTab pid={pid} />} />
189
192
  <Route path="artifacts" element={<ArtifactsTab pid={pid} />} />
@@ -1,13 +1,14 @@
1
1
  import { type ReactElement } from "react";
2
2
  import { useLocation, useNavigate } from "react-router-dom";
3
3
  import {
4
- Bot, Cpu, Database, Globe, IdCard, KeyRound, LayoutGrid, MessageCircle, Mic, Monitor, ScrollText, Send, Smartphone, Sparkles, User,
4
+ BellRing, Bot, Cpu, Database, Globe, IdCard, KeyRound, LayoutGrid, MessageCircle, Mic, Monitor, ScrollText, Send, Smartphone, Sparkles, User,
5
5
  } from "lucide-react";
6
6
  import { useNavCollapse, type TabSection } from "../components/common/TabNav";
7
7
  import { TabLayout } from "../components/common/TabLayout";
8
8
  import { IdentityPanel } from "../components/settings/IdentityPanel";
9
9
  import { SuperAgentPanel } from "../components/settings/SuperAgentPanel";
10
10
  import { ProfilePanel } from "../components/settings/ProfilePanel";
11
+ import { NudgePanel } from "../components/settings/NudgePanel";
11
12
  import { MemoryPanel } from "../components/settings/MemoryPanel";
12
13
  import { SkillsSettings } from "../components/settings/SkillsSettings";
13
14
  import { ModelsTab } from "./base/ModelsTab";
@@ -22,7 +23,7 @@ import { STORAGE } from "../constants";
22
23
  import { t } from "../i18n";
23
24
 
24
25
  type TabKey =
25
- | "identity" | "super_agent" | "profile" | "engines" | "memory" | "skills" | "telegram" | "devices"
26
+ | "identity" | "super_agent" | "profile" | "nudge" | "engines" | "memory" | "skills" | "telegram" | "devices"
26
27
  | "voice" | "deck" | "desktop" | "web" | "advanced";
27
28
 
28
29
  const SECTIONS: TabSection[] = [
@@ -32,11 +33,22 @@ const SECTIONS: TabSection[] = [
32
33
  { key: "identity", label: t("settings.tabs.identity"), icon: User },
33
34
  ],
34
35
  },
36
+ // The super-agent and the three screens that only describe IT: who it is,
37
+ // what line of work it has, and how often it may speak. The budget is core
38
+ // rather than profile-owned — it works with no profile installed — but it
39
+ // governs this agent's outbound messages, so it belongs next to them.
35
40
  {
36
- title: t("settings.agents_section"),
41
+ title: t("settings.super_agent_section"),
37
42
  items: [
38
43
  { key: "super_agent", label: t("settings.tabs.super_agent"), icon: Bot },
39
44
  { key: "profile", label: t("settings.tabs.profile"), icon: IdCard },
45
+ { key: "nudge", label: t("settings.tabs.nudge"), icon: BellRing },
46
+ ],
47
+ },
48
+ // Shared substrate: every agent uses these, not just the super-agent.
49
+ {
50
+ title: t("settings.knowledge_section"),
51
+ items: [
40
52
  { key: "engines", label: t("settings.tabs.engines"), icon: Cpu },
41
53
  { key: "memory", label: "Memory (RAG)", icon: Database },
42
54
  { key: "skills", label: t("skills_page.title"), icon: Sparkles },
@@ -76,6 +88,7 @@ const PANELS: Record<TabKey, () => ReactElement> = {
76
88
  identity: () => <IdentityPanel />,
77
89
  super_agent: () => <SuperAgentPanel />,
78
90
  profile: () => <ProfilePanel />,
91
+ nudge: () => <NudgePanel />,
79
92
  engines: () => <ModelsTab />,
80
93
  memory: () => <MemoryPanel />,
81
94
  skills: () => <SkillsSettings />,
@@ -117,6 +130,7 @@ function tabFromPath(pathname: string): TabKey {
117
130
  switch (raw) {
118
131
  case "super-agent": return "super_agent";
119
132
  case "profile": return "profile";
133
+ case "nudge": return "nudge";
120
134
  case "engines": return "engines";
121
135
  case "memory": return "memory";
122
136
  case "skills": return "skills";
@@ -0,0 +1,239 @@
1
+ import { useState } from "react";
2
+ import { useNavigate } from "react-router-dom";
3
+ import { Plus } from "lucide-react";
4
+ import { Commitments, type CommitmentState } from "../../lib/api/commitments";
5
+ import { Section } from "../../components/Section";
6
+ import { PagedList, usePagedQuery } from "../../components/Pager";
7
+ import { Badge, Button, Dialog, Empty, Field, FilterChips, Input, Loading } from "../../components/ui";
8
+ import { UiSelect } from "../../components/UiSelect";
9
+ import { useProjects } from "../../hooks/useProjects";
10
+ import { useToast } from "../../components/Toast";
11
+ import { t } from "../../i18n";
12
+
13
+ /**
14
+ * Commitments — what was promised, to whom, by when.
15
+ *
16
+ * Sits next to Tasks rather than inside it. The two answer different
17
+ * questions: a task is work, a commitment is someone waiting. Overdue is the
18
+ * default filter for exactly that reason — the expensive failure here is
19
+ * social, and it is silent unless something surfaces it.
20
+ *
21
+ * `pid` omitted = every project, which is the view that matches how promises
22
+ * are actually made.
23
+ */
24
+ export function CommitmentsTab({ pid }: { pid?: string }) {
25
+ const navigate = useNavigate();
26
+ const toast = useToast();
27
+ const [state, setState] = useState<CommitmentState | "all">("open");
28
+ const [overdueOnly, setOverdueOnly] = useState(false);
29
+
30
+ // Capturing a promise by hand matters even though the agent captures them
31
+ // from conversation: the moment you remember one is rarely the moment you
32
+ // are talking to it.
33
+ const [adding, setAdding] = useState(false);
34
+ const [who, setWho] = useState("");
35
+ const [what, setWhat] = useState("");
36
+ const [due, setDue] = useState("");
37
+ const [target, setTarget] = useState(pid ?? "");
38
+ const [busy, setBusy] = useState(false);
39
+ const { projects } = useProjects();
40
+
41
+ const paged = usePagedQuery({
42
+ key: `/api/commitments?pid=${pid ?? "all"}&state=${state}&overdue=${overdueOnly}`,
43
+ fetchPage: (limit, offset) =>
44
+ pid
45
+ ? Commitments.listPage(pid, { state, overdue: overdueOnly, limit, offset })
46
+ : Commitments.globalPage({ state, overdue: overdueOnly, limit, offset }),
47
+ resetKey: `${pid ?? "all"}|${state}|${overdueOnly}`,
48
+ });
49
+
50
+ const close = async (
51
+ projectId: string,
52
+ id: string,
53
+ how: "kept" | "missed",
54
+ ) => {
55
+ try {
56
+ await Commitments[how](projectId, id);
57
+ await paged.mutate();
58
+ } catch (e) {
59
+ toast.error((e as Error).message);
60
+ }
61
+ };
62
+
63
+ const isOverdue = (c: { state: string; due: string | null }) =>
64
+ c.state === "open" && !!c.due && c.due < new Date().toISOString();
65
+
66
+ const resetForm = () => { setWho(""); setWhat(""); setDue(""); setTarget(pid ?? ""); };
67
+
68
+ const create = async () => {
69
+ const projectId = pid ?? target;
70
+ if (!who.trim() || !what.trim() || !projectId) return;
71
+ setBusy(true);
72
+ try {
73
+ await Commitments.add(String(projectId), {
74
+ counterparty: who.trim(),
75
+ body: what.trim(),
76
+ // A date is optional here even though it is the useful part — refusing
77
+ // to record "I promised Ana the quote, no date yet" would mean the
78
+ // promise goes unrecorded, which is strictly worse.
79
+ due: due ? new Date(due).toISOString() : null,
80
+ });
81
+ await paged.mutate();
82
+ setAdding(false);
83
+ resetForm();
84
+ } catch (e) {
85
+ toast.error((e as Error).message);
86
+ } finally {
87
+ setBusy(false);
88
+ }
89
+ };
90
+
91
+ return (
92
+ <Section
93
+ fullHeight
94
+ title={t("project.commitments.title")}
95
+ description={t("project.commitments.subtitle")}
96
+ action={
97
+ <Button size="sm" variant="primary" onClick={() => setAdding(true)}>
98
+ <Plus size={14} /> {t("project.commitments.add")}
99
+ </Button>
100
+ }
101
+ filters={
102
+ <>
103
+ <FilterChips
104
+ value={state}
105
+ onChange={setState}
106
+ label={t("project.commitments.title")}
107
+ options={(["open", "kept", "missed", "all"] as const).map((s) => ({
108
+ value: s, label: t(`project.commitments.state.${s}`),
109
+ }))}
110
+ />
111
+ <Button
112
+ size="sm"
113
+ variant={overdueOnly ? "primary" : "ghost"}
114
+ onClick={() => setOverdueOnly((v) => !v)}
115
+ >
116
+ {t("project.commitments.overdue_only")}
117
+ </Button>
118
+ </>
119
+ }
120
+ >
121
+ {paged.isLoading && <Loading />}
122
+ {!paged.isLoading && paged.total === 0 && <Empty>{t("project.commitments.empty")}</Empty>}
123
+
124
+ <PagedList paged={paged} fullHeight>
125
+ <ul className="space-y-2 text-sm" data-testid="commitments-list">
126
+ {paged.items.map((c) => {
127
+ const projectId = String(
128
+ (c as { project_id?: string | number }).project_id ?? pid ?? "",
129
+ );
130
+ const projectName = (c as { project_name?: string }).project_name;
131
+ return (
132
+ <li
133
+ key={`${projectId}-${c.id}`}
134
+ className={`flex items-start gap-3 rounded-md border px-3 py-2 ${
135
+ isOverdue(c) ? "border-red-500/40 bg-red-500/5" : "border-border bg-muted/30"
136
+ }`}
137
+ >
138
+ {projectName ? (
139
+ <button
140
+ type="button"
141
+ onClick={() => navigate(`/p/${projectId}/commitments`)}
142
+ title={t("project.global_tasks.go_project")}
143
+ >
144
+ <Badge tone="info">{projectName.split("/").pop() || projectId}</Badge>
145
+ </button>
146
+ ) : null}
147
+
148
+ <div className="min-w-0 flex-1">
149
+ <div className="flex flex-wrap items-baseline gap-2">
150
+ {/* The counterparty leads. "Who is waiting" is the whole
151
+ reason this is not a task. */}
152
+ <span className="font-medium">{c.counterparty}</span>
153
+ <span className="opacity-80">— {c.body}</span>
154
+ </div>
155
+ <div className="mt-0.5 flex flex-wrap items-center gap-2 text-xs text-muted-fg">
156
+ {c.due ? (
157
+ <span className={isOverdue(c) ? "font-medium text-red-500" : ""}>
158
+ {isOverdue(c) ? t("project.commitments.overdue") : t("project.global_tasks.due")}{" "}
159
+ {c.due.slice(0, 10)}
160
+ </span>
161
+ ) : (
162
+ <span className="opacity-60">{t("project.commitments.no_date")}</span>
163
+ )}
164
+ {c.origin_channel ? <span>· {c.origin_channel}</span> : null}
165
+ {c.renegotiated_count ? (
166
+ <Badge tone="warning">
167
+ {t("project.commitments.moved")} ×{c.renegotiated_count}
168
+ </Badge>
169
+ ) : null}
170
+ {c.state !== "open" ? <Badge>{t(`project.commitments.state.${c.state}`)}</Badge> : null}
171
+ </div>
172
+ </div>
173
+
174
+ {c.state === "open" && projectId ? (
175
+ <div className="flex shrink-0 gap-1">
176
+ <Button size="sm" variant="ghost" onClick={() => close(projectId, c.id, "kept")}>
177
+ {t("project.commitments.mark_kept")}
178
+ </Button>
179
+ <Button size="sm" variant="ghost" onClick={() => close(projectId, c.id, "missed")}>
180
+ {t("project.commitments.mark_missed")}
181
+ </Button>
182
+ </div>
183
+ ) : null}
184
+ </li>
185
+ );
186
+ })}
187
+ </ul>
188
+ </PagedList>
189
+
190
+ <Dialog
191
+ open={adding}
192
+ onClose={() => { setAdding(false); resetForm(); }}
193
+ title={t("project.commitments.add_title")}
194
+ description={t("project.commitments.add_hint")}
195
+ footer={
196
+ <>
197
+ <Button onClick={() => { setAdding(false); resetForm(); }}>{t("common.cancel")}</Button>
198
+ <Button
199
+ variant="primary"
200
+ loading={busy}
201
+ disabled={!who.trim() || !what.trim() || !(pid ?? target)}
202
+ onClick={create}
203
+ >
204
+ {t("project.commitments.add")}
205
+ </Button>
206
+ </>
207
+ }
208
+ >
209
+ <div className="flex flex-col gap-3 p-5">
210
+ {/* Counterparty first — it is what makes this a commitment and not a
211
+ task, so the form asks for it before anything else. */}
212
+ <Field label={t("project.commitments.field_who")} hint={t("project.commitments.field_who_hint")}>
213
+ <Input value={who} onChange={(e) => setWho(e.target.value)} placeholder="Ana" autoFocus />
214
+ </Field>
215
+ <Field label={t("project.commitments.field_what")}>
216
+ <Input
217
+ value={what}
218
+ onChange={(e) => setWhat(e.target.value)}
219
+ placeholder={t("project.commitments.field_what_ph")}
220
+ onKeyDown={(e) => { if (e.key === "Enter" && who.trim() && what.trim()) create(); }}
221
+ />
222
+ </Field>
223
+ <Field label={t("project.commitments.field_due")} hint={t("project.commitments.field_due_hint")}>
224
+ <Input type="date" value={due} onChange={(e) => setDue(e.target.value)} />
225
+ </Field>
226
+ {!pid ? (
227
+ <Field label={t("project.commitments.field_project")}>
228
+ <UiSelect
229
+ value={String(target)}
230
+ onChange={setTarget}
231
+ options={projects.map((p) => ({ value: String(p.id), label: p.name || p.path }))}
232
+ />
233
+ </Field>
234
+ ) : null}
235
+ </div>
236
+ </Dialog>
237
+ </Section>
238
+ );
239
+ }