@heyramzi/tasks 0.0.0-stage → 0.4.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.
- package/LICENSE +21 -0
- package/README.md +285 -2
- package/package.json +98 -3
- package/src/ambient.d.ts +6 -0
- package/src/board-order.ts +137 -0
- package/src/dates.ts +31 -0
- package/src/define-statuses.ts +32 -0
- package/src/demo.svelte +394 -0
- package/src/icons/board-icon.svelte +11 -0
- package/src/icons/chevron-down.svg +1 -0
- package/src/icons/chevron-right.svg +1 -0
- package/src/icons/clock.svg +1 -0
- package/src/icons/close.svg +1 -0
- package/src/icons/gantt-icon.svelte +11 -0
- package/src/icons/list-icon.svelte +16 -0
- package/src/icons/milestone-flag.svg +1 -0
- package/src/icons/play.svg +1 -0
- package/src/icons/spinner.svg +1 -0
- package/src/inbox/icons/call.svg +1 -0
- package/src/inbox/icons/email.svg +1 -0
- package/src/inbox/icons/form.svg +1 -0
- package/src/inbox/icons/slack.svg +1 -0
- package/src/inbox/inbox-card.svelte +279 -0
- package/src/inbox/inbox-draft.svelte +84 -0
- package/src/inbox/inbox-logic.ts +56 -0
- package/src/inbox/inbox-statuses.ts +50 -0
- package/src/inbox/inbox-trace.svelte +44 -0
- package/src/inbox/inbox-types.ts +108 -0
- package/src/inbox/inbox.svelte +203 -0
- package/src/inbox/index.ts +23 -0
- package/src/index.ts +78 -0
- package/src/kanban-board.svelte +560 -0
- package/src/kanban-column.svelte +117 -0
- package/src/kanban-drag.ts +38 -0
- package/src/kanban-types.ts +65 -0
- package/src/lib/cn.ts +11 -0
- package/src/presets/sales.ts +16 -0
- package/src/reorder.ts +20 -0
- package/src/status-badge.svelte +57 -0
- package/src/status-dot.svelte +228 -0
- package/src/status-icon.svelte +30 -0
- package/src/status-types.ts +42 -0
- package/src/task-gantt.svelte +845 -0
- package/src/task-list.svelte +463 -0
- package/src/task-types.ts +43 -0
- package/src/task-view-switch.svelte +118 -0
- package/src/time/duration.ts +69 -0
- package/src/time/time-chip.svelte +62 -0
- package/src/time/time-dot.svelte +58 -0
- package/src/time/time-ring.svelte +74 -0
- package/src/time/time-tracker.svelte +567 -0
- package/src/time/time-types.ts +74 -0
- package/src/tone.ts +54 -0
- package/src/use-kanban-dnd.svelte.ts +173 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { untrack, type Snippet } from "svelte";
|
|
3
|
+
import { AgentAvatar } from "@heyramzi/ui/agents";
|
|
4
|
+
import { PixelLoader } from "@heyramzi/ui/ai/loading-state";
|
|
5
|
+
import { Button } from "@heyramzi/ui/shadcn/button";
|
|
6
|
+
import { Textarea } from "@heyramzi/ui/shadcn/textarea";
|
|
7
|
+
import { cn } from "../lib/cn.js";
|
|
8
|
+
import StatusBadge from "../status-badge.svelte";
|
|
9
|
+
import StatusDot from "../status-dot.svelte";
|
|
10
|
+
import { TONE_TEXT } from "../tone.js";
|
|
11
|
+
import InboxDraftEditor from "./inbox-draft.svelte";
|
|
12
|
+
import { canUndo, isOpen, startDraft } from "./inbox-logic.js";
|
|
13
|
+
import {
|
|
14
|
+
SOURCE_ICON,
|
|
15
|
+
SOURCE_LABEL,
|
|
16
|
+
confidenceLevel,
|
|
17
|
+
inboxConfidence,
|
|
18
|
+
inboxKinds,
|
|
19
|
+
inboxStates,
|
|
20
|
+
} from "./inbox-statuses.js";
|
|
21
|
+
import InboxTraceView from "./inbox-trace.svelte";
|
|
22
|
+
import type { InboxDraftEdits, InboxItem, InboxVerdict } from "./inbox-types.js";
|
|
23
|
+
|
|
24
|
+
type Props = {
|
|
25
|
+
item: InboxItem;
|
|
26
|
+
/** The keyboard cursor is on this card. */
|
|
27
|
+
selected?: boolean;
|
|
28
|
+
onselect?: () => void;
|
|
29
|
+
onverdict: (verdict: InboxVerdict) => void;
|
|
30
|
+
/** Replaces the summary lines, for a host that renders its own proposal. */
|
|
31
|
+
renderProposal?: Snippet<[InboxItem]>;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
let { item, selected = false, onselect, onverdict, renderProposal }: Props = $props();
|
|
35
|
+
|
|
36
|
+
let drafts = $derived(item.proposal.drafts ?? []);
|
|
37
|
+
let open = $derived(isOpen(item));
|
|
38
|
+
let level = $derived(confidenceLevel(item.confidence.score));
|
|
39
|
+
let tone = $derived(inboxConfidence.tone(level));
|
|
40
|
+
|
|
41
|
+
let pickedDraft = $state<string>();
|
|
42
|
+
let edits = $state<InboxDraftEdits>({});
|
|
43
|
+
let bodyEl = $state<HTMLTextAreaElement | null>(null);
|
|
44
|
+
let steering = $state(false);
|
|
45
|
+
let note = $state("");
|
|
46
|
+
let noteEl = $state<HTMLTextAreaElement | null>(null);
|
|
47
|
+
let copied = $state(false);
|
|
48
|
+
|
|
49
|
+
// WHY the selection is derived from the pick: a revised proposal brings new
|
|
50
|
+
// drafts, and the old pick may not exist in them. The agent's choice wins then.
|
|
51
|
+
let selectedDraft = $derived(
|
|
52
|
+
drafts.find((d) => d.templateId === pickedDraft) ??
|
|
53
|
+
startDraft(drafts, item.proposal.recommended),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
// WHY: when the agent hands back a new set of drafts, the person's old edits
|
|
57
|
+
// belong to text that no longer exists.
|
|
58
|
+
let seenDrafts = untrack(() => item.proposal.drafts);
|
|
59
|
+
$effect.pre(() => {
|
|
60
|
+
if (item.proposal.drafts === seenDrafts) return;
|
|
61
|
+
seenDrafts = item.proposal.drafts;
|
|
62
|
+
edits = {};
|
|
63
|
+
pickedDraft = undefined;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
function current() {
|
|
67
|
+
if (!selectedDraft) return undefined;
|
|
68
|
+
const text = edits[selectedDraft.templateId] ?? selectedDraft;
|
|
69
|
+
return { templateId: selectedDraft.templateId, subject: text.subject, body: text.body };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function approve() {
|
|
73
|
+
if (!open) return;
|
|
74
|
+
// Sending the decline template closes the item as declined, whichever button sent it.
|
|
75
|
+
const type = selectedDraft?.outcome === "decline" ? "decline" : "approve";
|
|
76
|
+
onverdict({ type, id: item.id, draft: current() });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function decline() {
|
|
80
|
+
if (!open) return;
|
|
81
|
+
const template = drafts.find((d) => d.outcome === "decline");
|
|
82
|
+
// With a decline template, the shortcut loads it for editing. Sending stays an explicit Send.
|
|
83
|
+
if (template) {
|
|
84
|
+
pickedDraft = template.templateId;
|
|
85
|
+
edit();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
onverdict({ type: "decline", id: item.id });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function steer() {
|
|
92
|
+
if (!open) return;
|
|
93
|
+
steering = true;
|
|
94
|
+
queueMicrotask(() => noteEl?.focus());
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function edit() {
|
|
98
|
+
if (!open || !drafts.length) return;
|
|
99
|
+
queueMicrotask(() => bodyEl?.focus());
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function undo() {
|
|
103
|
+
if (canUndo(item)) onverdict({ type: "undo", id: item.id });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function submitSteer() {
|
|
107
|
+
const text = note.trim();
|
|
108
|
+
if (!text) return;
|
|
109
|
+
onverdict({ type: "steer", id: item.id, note: text });
|
|
110
|
+
steering = false;
|
|
111
|
+
note = "";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function onNoteKeydown(event: KeyboardEvent) {
|
|
115
|
+
if (event.key === "Escape") {
|
|
116
|
+
steering = false;
|
|
117
|
+
event.stopPropagation();
|
|
118
|
+
} else if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
|
119
|
+
event.preventDefault();
|
|
120
|
+
submitSteer();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function copy() {
|
|
125
|
+
const draft = current();
|
|
126
|
+
if (!draft) return;
|
|
127
|
+
try {
|
|
128
|
+
await navigator.clipboard.writeText(`${draft.subject}\n\n${draft.body}`);
|
|
129
|
+
copied = true;
|
|
130
|
+
setTimeout(() => (copied = false), 1600);
|
|
131
|
+
} catch {
|
|
132
|
+
copied = false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let sendLabel = $derived(
|
|
137
|
+
drafts.length > 1 && selectedDraft
|
|
138
|
+
? `Send ${selectedDraft.label.toLowerCase()}`
|
|
139
|
+
: drafts.length
|
|
140
|
+
? "Approve and send"
|
|
141
|
+
: "Approve",
|
|
142
|
+
);
|
|
143
|
+
</script>
|
|
144
|
+
|
|
145
|
+
<!-- WHY a bordered card only while it waits: a decided or automatic item is a
|
|
146
|
+
line in a log, so it loses the border and the queue stays scannable. -->
|
|
147
|
+
<!-- WHY a focusable article: j and k move focus card to card, and a screen reader
|
|
148
|
+
reads the card as one labelled unit. -->
|
|
149
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
150
|
+
<article
|
|
151
|
+
data-inbox-item={item.id}
|
|
152
|
+
data-selected={selected || undefined}
|
|
153
|
+
aria-label={item.title}
|
|
154
|
+
tabindex={selected ? 0 : -1}
|
|
155
|
+
onfocusin={onselect}
|
|
156
|
+
onpointerdown={onselect}
|
|
157
|
+
class={cn(
|
|
158
|
+
"flex flex-col gap-3 rounded-xl p-3 outline-none transition-[background-color,border-color,box-shadow] duration-[var(--duration-quick,120ms)] ease-[var(--ease-smooth-out,ease-out)]",
|
|
159
|
+
"focus-visible:ring-ring/50 focus-visible:ring-3",
|
|
160
|
+
open ? "border-border bg-card border shadow-[var(--elev-card,none)]" : "bg-muted/30 border border-transparent",
|
|
161
|
+
selected && open && "border-ring",
|
|
162
|
+
)}
|
|
163
|
+
>
|
|
164
|
+
<header class="flex items-start gap-2.5">
|
|
165
|
+
<StatusDot statuses={inboxStates} status={item.state} class="mt-0.5" />
|
|
166
|
+
<span class="sr-only">{inboxStates.label(item.state)}.</span>
|
|
167
|
+
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
|
168
|
+
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
169
|
+
<h3 class="text-sm leading-5 font-medium text-balance">{item.title}</h3>
|
|
170
|
+
<StatusBadge statuses={inboxKinds} status={item.kind} />
|
|
171
|
+
{#if !open}
|
|
172
|
+
<StatusBadge statuses={inboxStates} status={item.state} />
|
|
173
|
+
{/if}
|
|
174
|
+
</div>
|
|
175
|
+
{#if renderProposal}
|
|
176
|
+
{@render renderProposal(item)}
|
|
177
|
+
{:else}
|
|
178
|
+
<p class="text-muted-foreground text-sm text-pretty">{item.proposal.summary}</p>
|
|
179
|
+
{#if item.proposal.detail}
|
|
180
|
+
<p class="text-muted-foreground text-sm text-pretty">{item.proposal.detail}</p>
|
|
181
|
+
{/if}
|
|
182
|
+
{/if}
|
|
183
|
+
</div>
|
|
184
|
+
</header>
|
|
185
|
+
|
|
186
|
+
<div class="text-muted-foreground flex flex-wrap items-center gap-x-4 gap-y-1 pl-6 text-xs">
|
|
187
|
+
<span class="inline-flex items-center gap-1.5">
|
|
188
|
+
<span class="size-3.5 [&>svg]:size-full" aria-hidden="true">{@html SOURCE_ICON[item.trace.source.type]}</span>
|
|
189
|
+
{SOURCE_LABEL[item.trace.source.type]}, {item.trace.source.at}
|
|
190
|
+
</span>
|
|
191
|
+
<span class={cn("inline-flex items-center gap-1.5 tabular-nums", TONE_TEXT[tone])}>
|
|
192
|
+
<StatusDot
|
|
193
|
+
statuses={inboxConfidence}
|
|
194
|
+
status={level}
|
|
195
|
+
progress={item.confidence.score}
|
|
196
|
+
size={12}
|
|
197
|
+
animate={false}
|
|
198
|
+
/>
|
|
199
|
+
{Math.round(item.confidence.score * 100)}% sure
|
|
200
|
+
</span>
|
|
201
|
+
{#if item.agent}
|
|
202
|
+
<span class="inline-flex items-center gap-1.5">
|
|
203
|
+
<AgentAvatar
|
|
204
|
+
name={item.agent.name}
|
|
205
|
+
shape={item.agent.shape}
|
|
206
|
+
color={item.agent.color}
|
|
207
|
+
state={item.state === "working" ? "working" : "default"}
|
|
208
|
+
size={16}
|
|
209
|
+
/>
|
|
210
|
+
{item.agent.name}
|
|
211
|
+
</span>
|
|
212
|
+
{/if}
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
{#if item.confidence.missing && level !== "high" && open}
|
|
216
|
+
<p class={cn("pl-6 text-sm", TONE_TEXT[tone])}>Missing: {item.confidence.missing}</p>
|
|
217
|
+
{/if}
|
|
218
|
+
|
|
219
|
+
<div class="pl-6">
|
|
220
|
+
<InboxTraceView trace={item.trace} />
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
{#if item.state === "working"}
|
|
224
|
+
<div class="flex flex-col gap-1.5 pl-6">
|
|
225
|
+
<PixelLoader label="Agent is re-running with your note" />
|
|
226
|
+
{#if item.steer}
|
|
227
|
+
<p class="text-muted-foreground border-border border-l-2 pl-3 text-sm">“{item.steer}”</p>
|
|
228
|
+
{/if}
|
|
229
|
+
</div>
|
|
230
|
+
{/if}
|
|
231
|
+
|
|
232
|
+
{#if open && drafts.length && selectedDraft}
|
|
233
|
+
<div class="pl-6">
|
|
234
|
+
<InboxDraftEditor
|
|
235
|
+
{drafts}
|
|
236
|
+
recommended={item.proposal.recommended}
|
|
237
|
+
selected={selectedDraft.templateId}
|
|
238
|
+
bind:edits
|
|
239
|
+
bind:bodyEl
|
|
240
|
+
onpick={(id) => (pickedDraft = id)}
|
|
241
|
+
/>
|
|
242
|
+
</div>
|
|
243
|
+
{/if}
|
|
244
|
+
|
|
245
|
+
{#if open && steering}
|
|
246
|
+
<div class="flex flex-col gap-2 pl-6">
|
|
247
|
+
<Textarea
|
|
248
|
+
bind:ref={noteEl}
|
|
249
|
+
bind:value={note}
|
|
250
|
+
onkeydown={onNoteKeydown}
|
|
251
|
+
placeholder="Tell the agent what to do instead"
|
|
252
|
+
aria-label="Note for the agent"
|
|
253
|
+
class="bg-background min-h-20 text-sm"
|
|
254
|
+
/>
|
|
255
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
256
|
+
<Button size="sm" onclick={submitSteer} disabled={!note.trim()}>Send to agent</Button>
|
|
257
|
+
<Button size="sm" variant="ghost" onclick={() => (steering = false)}>Cancel</Button>
|
|
258
|
+
<span class="text-muted-foreground text-xs max-sm:hidden">The agent retries with your note.</span>
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
{:else if open}
|
|
262
|
+
<div class="flex flex-wrap items-center gap-2 pl-6">
|
|
263
|
+
<Button size="sm" onclick={approve} aria-keyshortcuts="a">{sendLabel}</Button>
|
|
264
|
+
{#if !drafts.some((d) => d.outcome === "decline")}
|
|
265
|
+
<Button size="sm" variant="destructive" onclick={decline} aria-keyshortcuts="d">Decline</Button>
|
|
266
|
+
{/if}
|
|
267
|
+
<Button size="sm" variant="outline" onclick={steer} aria-keyshortcuts="s">Steer</Button>
|
|
268
|
+
{#if drafts.length}
|
|
269
|
+
<Button size="sm" variant="ghost" onclick={copy} class="ml-auto" aria-live="polite">
|
|
270
|
+
{copied ? "Copied" : "Copy"}
|
|
271
|
+
</Button>
|
|
272
|
+
{/if}
|
|
273
|
+
</div>
|
|
274
|
+
{:else if canUndo(item)}
|
|
275
|
+
<div class="pl-6">
|
|
276
|
+
<Button size="sm" variant="outline" onclick={undo} aria-keyshortcuts="u">Undo</Button>
|
|
277
|
+
</div>
|
|
278
|
+
{/if}
|
|
279
|
+
</article>
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Input } from "@heyramzi/ui/shadcn/input";
|
|
3
|
+
import { Textarea } from "@heyramzi/ui/shadcn/textarea";
|
|
4
|
+
import { ToggleGroup, ToggleGroupItem } from "@heyramzi/ui/shadcn/toggle-group";
|
|
5
|
+
import { cn } from "../lib/cn.js";
|
|
6
|
+
import { insetWellClass } from "../tone.js";
|
|
7
|
+
import type { InboxDraft, InboxDraftEdits } from "./inbox-types.js";
|
|
8
|
+
|
|
9
|
+
type Props = {
|
|
10
|
+
drafts: InboxDraft[];
|
|
11
|
+
/** The template the agent picked. Marked in the picker. */
|
|
12
|
+
recommended?: string;
|
|
13
|
+
selected: string;
|
|
14
|
+
/** What the person typed, by template. A template with no entry shows the agent's text. */
|
|
15
|
+
edits: InboxDraftEdits;
|
|
16
|
+
/** The person picked another template. */
|
|
17
|
+
onpick: (templateId: string) => void;
|
|
18
|
+
/** The body field, so a shortcut can focus it. */
|
|
19
|
+
bodyEl?: HTMLTextAreaElement | null;
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
class?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
let {
|
|
25
|
+
drafts,
|
|
26
|
+
recommended,
|
|
27
|
+
selected,
|
|
28
|
+
onpick,
|
|
29
|
+
edits = $bindable(),
|
|
30
|
+
bodyEl = $bindable(null),
|
|
31
|
+
disabled = false,
|
|
32
|
+
class: className,
|
|
33
|
+
}: Props = $props();
|
|
34
|
+
|
|
35
|
+
let draft = $derived(drafts.find((d) => d.templateId === selected) ?? drafts[0]);
|
|
36
|
+
let text = $derived(edits[draft.templateId] ?? { subject: draft.subject, body: draft.body });
|
|
37
|
+
let edited = $derived(text.subject !== draft.subject || text.body !== draft.body);
|
|
38
|
+
|
|
39
|
+
function write(patch: Partial<{ subject: string; body: string }>) {
|
|
40
|
+
edits[draft.templateId] = { ...text, ...patch };
|
|
41
|
+
}
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<div class={cn(insetWellClass, "flex flex-col gap-2", className)}>
|
|
45
|
+
{#if drafts.length > 1}
|
|
46
|
+
<ToggleGroup
|
|
47
|
+
type="single"
|
|
48
|
+
variant="outline"
|
|
49
|
+
size="sm"
|
|
50
|
+
value={selected}
|
|
51
|
+
onValueChange={(next) => next && onpick(next)}
|
|
52
|
+
aria-label="Reply template"
|
|
53
|
+
class="flex-wrap"
|
|
54
|
+
>
|
|
55
|
+
{#each drafts as d (d.templateId)}
|
|
56
|
+
<ToggleGroupItem value={d.templateId} {disabled}>
|
|
57
|
+
{d.label}
|
|
58
|
+
{#if d.templateId === recommended}
|
|
59
|
+
<span class="text-muted-foreground text-xs font-normal">suggested</span>
|
|
60
|
+
{/if}
|
|
61
|
+
</ToggleGroupItem>
|
|
62
|
+
{/each}
|
|
63
|
+
</ToggleGroup>
|
|
64
|
+
{/if}
|
|
65
|
+
|
|
66
|
+
<Input
|
|
67
|
+
value={text.subject}
|
|
68
|
+
oninput={(event) => write({ subject: event.currentTarget.value })}
|
|
69
|
+
aria-label="Subject"
|
|
70
|
+
{disabled}
|
|
71
|
+
class="bg-background"
|
|
72
|
+
/>
|
|
73
|
+
<Textarea
|
|
74
|
+
bind:ref={bodyEl}
|
|
75
|
+
value={text.body}
|
|
76
|
+
oninput={(event) => write({ body: event.currentTarget.value })}
|
|
77
|
+
aria-label="Email body"
|
|
78
|
+
{disabled}
|
|
79
|
+
class="bg-background min-h-28 text-sm"
|
|
80
|
+
/>
|
|
81
|
+
<p class="text-muted-foreground text-xs" aria-live="polite">
|
|
82
|
+
{edited ? "Edited by you. Send uses your text." : "The agent's draft. Edit it before you send."}
|
|
83
|
+
</p>
|
|
84
|
+
</div>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
InboxDraft,
|
|
3
|
+
InboxItem,
|
|
4
|
+
InboxKindFilter,
|
|
5
|
+
InboxLane,
|
|
6
|
+
InboxProject,
|
|
7
|
+
InboxVerdict,
|
|
8
|
+
} from "./inbox-types.js";
|
|
9
|
+
|
|
10
|
+
/** Which lane an item sits in. The agent's own work and the person's decisions never share one. */
|
|
11
|
+
export function laneOf(item: InboxItem): InboxLane {
|
|
12
|
+
if (item.tier === "auto") return "auto";
|
|
13
|
+
return item.state === "proposed" || item.state === "working" ? "needs" : "decided";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function inLane(items: InboxItem[], lane: InboxLane, kind: InboxKindFilter): InboxItem[] {
|
|
17
|
+
return items.filter((item) => laneOf(item) === lane && (kind === "all" || item.kind === kind));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type InboxGroup = { project: InboxProject; items: InboxItem[] };
|
|
21
|
+
|
|
22
|
+
/** Group by project in order of first appearance, so the oldest waiting item keeps its place. */
|
|
23
|
+
export function groupByProject(items: InboxItem[]): InboxGroup[] {
|
|
24
|
+
const groups = new Map<string, InboxGroup>();
|
|
25
|
+
for (const item of items) {
|
|
26
|
+
const group = groups.get(item.project.id);
|
|
27
|
+
if (group) group.items.push(item);
|
|
28
|
+
else groups.set(item.project.id, { project: item.project, items: [item] });
|
|
29
|
+
}
|
|
30
|
+
return [...groups.values()];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Items in the order the screen shows them, so j and k walk what the eye sees. */
|
|
34
|
+
export function flatten(groups: InboxGroup[]): InboxItem[] {
|
|
35
|
+
return groups.flatMap((group) => group.items);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The draft to open on: the agent's pick, else the first. */
|
|
39
|
+
export function startDraft(drafts: InboxDraft[], recommended?: string): InboxDraft | undefined {
|
|
40
|
+
return drafts.find((draft) => draft.templateId === recommended) ?? drafts[0];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A person can act on an item only while it waits for them. */
|
|
44
|
+
export function isOpen(item: InboxItem): boolean {
|
|
45
|
+
return item.tier === "ask" && item.state === "proposed";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function canUndo(item: InboxItem): boolean {
|
|
49
|
+
return item.tier === "auto" && item.state === "done";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** True when a key press belongs to a text field and so isn't a shortcut. */
|
|
53
|
+
export function isTyping(target: EventTarget | null): boolean {
|
|
54
|
+
if (!(target instanceof HTMLElement)) return false;
|
|
55
|
+
return target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName);
|
|
56
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { defineStatuses } from "../define-statuses.js";
|
|
2
|
+
import callIcon from "./icons/call.svg?raw";
|
|
3
|
+
import emailIcon from "./icons/email.svg?raw";
|
|
4
|
+
import formIcon from "./icons/form.svg?raw";
|
|
5
|
+
import slackIcon from "./icons/slack.svg?raw";
|
|
6
|
+
import type { InboxSourceType } from "./inbox-types.js";
|
|
7
|
+
|
|
8
|
+
/** The lifecycle of an item. The dot and the badge both read this one map. */
|
|
9
|
+
export const inboxStates = defineStatuses({
|
|
10
|
+
proposed: { label: "Needs you", tone: "warning", variant: "open" },
|
|
11
|
+
working: { label: "Agent working", tone: "accent", variant: "progress", progress: 0.6 },
|
|
12
|
+
approved: { label: "Approved", tone: "success", variant: "completed" },
|
|
13
|
+
declined: { label: "Declined", tone: "neutral", variant: "lost" },
|
|
14
|
+
done: { label: "Done by agent", tone: "success", variant: "completed" },
|
|
15
|
+
undone: { label: "Undone", tone: "neutral", variant: "lost" },
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/** What the agent made of the touchpoint. */
|
|
19
|
+
export const inboxKinds = defineStatuses({
|
|
20
|
+
information: { label: "Information", tone: "info", variant: "filled" },
|
|
21
|
+
action: { label: "Action", tone: "accent", variant: "filled" },
|
|
22
|
+
decision: { label: "Decision", tone: "warning", variant: "filled" },
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** How sure the agent is, bucketed. The dot's fill carries the exact score. */
|
|
26
|
+
export const inboxConfidence = defineStatuses({
|
|
27
|
+
high: { label: "Confident", tone: "success", variant: "progress" },
|
|
28
|
+
medium: { label: "Unsure", tone: "warning", variant: "progress" },
|
|
29
|
+
low: { label: "Guessing", tone: "destructive", variant: "progress" },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export function confidenceLevel(score: number): "high" | "medium" | "low" {
|
|
33
|
+
if (score >= 0.8) return "high";
|
|
34
|
+
if (score >= 0.5) return "medium";
|
|
35
|
+
return "low";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const SOURCE_ICON: Record<InboxSourceType, string> = {
|
|
39
|
+
call: callIcon,
|
|
40
|
+
email: emailIcon,
|
|
41
|
+
slack: slackIcon,
|
|
42
|
+
form: formIcon,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const SOURCE_LABEL: Record<InboxSourceType, string> = {
|
|
46
|
+
call: "Call",
|
|
47
|
+
email: "Email",
|
|
48
|
+
slack: "Slack",
|
|
49
|
+
form: "Form",
|
|
50
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Reasoning, ReasoningContent, ReasoningTrigger } from "@heyramzi/ui/ai/reasoning";
|
|
3
|
+
import { SOURCE_ICON, SOURCE_LABEL } from "./inbox-statuses.js";
|
|
4
|
+
import type { InboxTrace } from "./inbox-types.js";
|
|
5
|
+
|
|
6
|
+
type Props = { trace: InboxTrace };
|
|
7
|
+
|
|
8
|
+
let { trace }: Props = $props();
|
|
9
|
+
|
|
10
|
+
let count = $derived(trace.evidence.length);
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<!-- WHY the shared Reasoning and not a local disclosure: it is already the
|
|
14
|
+
collapsed-by-default "how the agent got here" pattern, with the same chevron. -->
|
|
15
|
+
<Reasoning>
|
|
16
|
+
<ReasoningTrigger class="w-auto">
|
|
17
|
+
<span class="size-3.5 shrink-0 [&>svg]:size-full" aria-hidden="true">{@html SOURCE_ICON[trace.source.type]}</span>
|
|
18
|
+
<span>
|
|
19
|
+
Trace: {SOURCE_LABEL[trace.source.type]}, {trace.source.at}, {count}
|
|
20
|
+
{count === 1 ? "quote" : "quotes"}
|
|
21
|
+
</span>
|
|
22
|
+
</ReasoningTrigger>
|
|
23
|
+
<ReasoningContent>
|
|
24
|
+
<div class="flex flex-col gap-3">
|
|
25
|
+
<p class="text-foreground">
|
|
26
|
+
{trace.source.label}
|
|
27
|
+
{#if trace.source.href}
|
|
28
|
+
<a href={trace.source.href} class="text-foreground ml-1 underline underline-offset-2">Open source</a>
|
|
29
|
+
{/if}
|
|
30
|
+
</p>
|
|
31
|
+
<ul class="flex flex-col gap-2">
|
|
32
|
+
{#each trace.evidence as evidence, i (i)}
|
|
33
|
+
<li>
|
|
34
|
+
<blockquote class="text-foreground">“{evidence.quote}”</blockquote>
|
|
35
|
+
{#if evidence.by || evidence.at}
|
|
36
|
+
<span class="text-xs tabular-nums">{[evidence.by, evidence.at].filter(Boolean).join(", ")}</span>
|
|
37
|
+
{/if}
|
|
38
|
+
</li>
|
|
39
|
+
{/each}
|
|
40
|
+
</ul>
|
|
41
|
+
<p><span class="text-foreground font-medium">Why:</span> {trace.reasoning}</p>
|
|
42
|
+
</div>
|
|
43
|
+
</ReasoningContent>
|
|
44
|
+
</Reasoning>
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { AgentShape } from "@heyramzi/ui/agents";
|
|
2
|
+
|
|
3
|
+
/** What the agent decided the touchpoint is: a fact to file, a task to do, or a call only a person makes. */
|
|
4
|
+
export type InboxKind = "information" | "action" | "decision";
|
|
5
|
+
|
|
6
|
+
/** `auto` was already done by the agent and can be undone. `ask` waits for a verdict. */
|
|
7
|
+
export type InboxTier = "auto" | "ask";
|
|
8
|
+
|
|
9
|
+
export type InboxState = "proposed" | "working" | "approved" | "declined" | "done" | "undone";
|
|
10
|
+
|
|
11
|
+
export type InboxSourceType = "call" | "email" | "slack" | "form";
|
|
12
|
+
|
|
13
|
+
/** The three outcomes of a reply draft. Decline is the only one that closes the item without a yes. */
|
|
14
|
+
export type InboxOutcome = "approve" | "decline" | "clarify";
|
|
15
|
+
|
|
16
|
+
/** An editable email, filled in from a template by the agent. */
|
|
17
|
+
export type InboxDraft = {
|
|
18
|
+
/** The template it came from. Comes back in the verdict so the host knows which one was sent. */
|
|
19
|
+
templateId: string;
|
|
20
|
+
/** The button's word: "Pre-qualify", "Decline", "Clarify". */
|
|
21
|
+
label: string;
|
|
22
|
+
subject: string;
|
|
23
|
+
body: string;
|
|
24
|
+
outcome?: InboxOutcome;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** What the agent wants to do, in one line, with the drafts it prepared. */
|
|
28
|
+
export type InboxProposal = {
|
|
29
|
+
summary: string;
|
|
30
|
+
detail?: string;
|
|
31
|
+
drafts?: InboxDraft[];
|
|
32
|
+
/** The draft to open on. Defaults to the first. */
|
|
33
|
+
recommended?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** A short quote the agent read. */
|
|
37
|
+
export type InboxEvidence = {
|
|
38
|
+
quote: string;
|
|
39
|
+
/** Who said it, or where it sat: "Priya", "line 14". */
|
|
40
|
+
by?: string;
|
|
41
|
+
/** A position in the source, such as "12:41" in a call. */
|
|
42
|
+
at?: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Where an item came from and what the agent made of it. Every verdict is traceable to this. */
|
|
46
|
+
export type InboxTrace = {
|
|
47
|
+
source: {
|
|
48
|
+
type: InboxSourceType;
|
|
49
|
+
label: string;
|
|
50
|
+
/** Display time, formatted by the host so server and browser agree. */
|
|
51
|
+
at: string;
|
|
52
|
+
href?: string;
|
|
53
|
+
};
|
|
54
|
+
evidence: InboxEvidence[];
|
|
55
|
+
reasoning: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type InboxConfidence = {
|
|
59
|
+
/** 0 to 1. */
|
|
60
|
+
score: number;
|
|
61
|
+
/** What the agent couldn't tell. Shown when the agent is unsure. */
|
|
62
|
+
missing?: string;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type InboxProject = {
|
|
66
|
+
id: string;
|
|
67
|
+
name: string;
|
|
68
|
+
client?: string;
|
|
69
|
+
/** The project timeline. Decided items link here. */
|
|
70
|
+
href?: string;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type InboxAgent = {
|
|
74
|
+
name: string;
|
|
75
|
+
shape?: AgentShape;
|
|
76
|
+
color?: string;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export type InboxItem = {
|
|
80
|
+
id: string;
|
|
81
|
+
kind: InboxKind;
|
|
82
|
+
tier: InboxTier;
|
|
83
|
+
state: InboxState;
|
|
84
|
+
title: string;
|
|
85
|
+
project: InboxProject;
|
|
86
|
+
proposal: InboxProposal;
|
|
87
|
+
confidence: InboxConfidence;
|
|
88
|
+
trace: InboxTrace;
|
|
89
|
+
agent?: InboxAgent;
|
|
90
|
+
/** The last steer note, shown while the agent re-runs. */
|
|
91
|
+
steer?: string;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** The draft as the person left it, after editing. */
|
|
95
|
+
export type InboxDraftEdit = Pick<InboxDraft, "templateId" | "subject" | "body">;
|
|
96
|
+
|
|
97
|
+
export type InboxVerdict =
|
|
98
|
+
| { type: "approve"; id: string; draft?: InboxDraftEdit }
|
|
99
|
+
| { type: "decline"; id: string; draft?: InboxDraftEdit }
|
|
100
|
+
| { type: "steer"; id: string; note: string }
|
|
101
|
+
| { type: "undo"; id: string };
|
|
102
|
+
|
|
103
|
+
export type InboxLane = "needs" | "auto" | "decided";
|
|
104
|
+
|
|
105
|
+
export type InboxKindFilter = "all" | InboxKind;
|
|
106
|
+
|
|
107
|
+
/** What the person typed into the drafts, by template id. */
|
|
108
|
+
export type InboxDraftEdits = Record<string, { subject: string; body: string }>;
|