@lotics/ui 43.2.0 → 43.3.1
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/AGENTS.md +4 -9
- package/MIGRATION.md +44 -0
- package/docs/ai_patterns.md +11 -18
- package/docs/catalog.md +20 -28
- package/docs/composition.md +5 -16
- package/docs/data_entry.md +54 -38
- package/docs/templates.md +133 -48
- package/examples/tpl_record.tsx +349 -272
- package/package.json +1 -1
- package/src/inline_edit.tsx +34 -1
- package/src/inline_text_input.tsx +19 -4
- package/src/timeline.tsx +16 -5
package/examples/tpl_record.tsx
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import { Fragment, useEffect, useRef, useState, type ReactNode, useMemo } from "react";
|
|
2
2
|
import { Pressable, ScrollView, View } from "react-native";
|
|
3
3
|
import { Text } from "@lotics/ui/text";
|
|
4
|
-
import { colors, solid } from "@lotics/ui/colors";
|
|
4
|
+
import { colors, solid, withAlpha } from "@lotics/ui/colors";
|
|
5
5
|
import { DRAWER_GUTTER } from "@lotics/ui/drawer";
|
|
6
6
|
import { Button } from "@lotics/ui/button";
|
|
7
7
|
import { BackButton } from "@lotics/ui/back_button";
|
|
8
8
|
import { Divider } from "@lotics/ui/divider";
|
|
9
9
|
import { Link } from "@lotics/ui/link";
|
|
10
10
|
import { Icon, type IconName } from "@lotics/ui/icon";
|
|
11
|
-
import { Timeline, type TimelineItem } from "@lotics/ui/timeline";
|
|
12
11
|
import { MediaPlayer } from "@lotics/ui/media_player";
|
|
13
12
|
import { Markdown } from "@lotics/ui/markdown";
|
|
14
13
|
import { TextDisclosure } from "@lotics/ui/text_disclosure";
|
|
@@ -40,6 +39,7 @@ import { FileBadge } from "@lotics/ui/file_badge";
|
|
|
40
39
|
import { FileRows } from "@lotics/ui/file_rows";
|
|
41
40
|
import { InlineFiles } from "@lotics/ui/inline_files";
|
|
42
41
|
import { FileGrid } from "@lotics/ui/file_grid";
|
|
42
|
+
import { FilesEditor } from "@lotics/ui/files_editor";
|
|
43
43
|
import { ActionMenu, type ActionMenuItem } from "@lotics/ui/action_menu";
|
|
44
44
|
import { InlineStatic } from "@lotics/ui/inline_static";
|
|
45
45
|
import { Ledger, LedgerGroup, LedgerRow, LedgerTotal } from "@lotics/ui/ledger";
|
|
@@ -52,12 +52,9 @@ import { MemberSelect, type MemberSelectMember } from "@lotics/ui/member_select"
|
|
|
52
52
|
import { EmptyState } from "@lotics/ui/empty_state";
|
|
53
53
|
import { FormField } from "@lotics/ui/form_field";
|
|
54
54
|
import { TextInputField } from "@lotics/ui/text_input_field";
|
|
55
|
-
import { FileThumbnail, type DisplayFile } from "@lotics/ui/file_thumbnail";
|
|
55
|
+
import { FileThumbnail, THUMBNAIL_SIZE, type DisplayFile } from "@lotics/ui/file_thumbnail";
|
|
56
56
|
import { FileThumbnailGrid } from "@lotics/ui/file_thumbnail_grid";
|
|
57
|
-
import { MemberChip } from "@lotics/ui/member_chip";
|
|
58
|
-
import { WaveAvatar } from "@lotics/ui/wave_avatar";
|
|
59
57
|
import { Inset } from "@lotics/ui/inset";
|
|
60
|
-
import { isImageMimeType } from "@lotics/ui/mime";
|
|
61
58
|
import { FileGalleryModal } from "@lotics/ui/file_gallery_modal";
|
|
62
59
|
import { DangerZone } from "@lotics/ui/danger_zone";
|
|
63
60
|
import { FileRow } from "@lotics/ui/file_row";
|
|
@@ -330,19 +327,9 @@ interface ActivityEntry {
|
|
|
330
327
|
* and the row has to say so rather than borrow a phrase from the enums.
|
|
331
328
|
*/
|
|
332
329
|
gist?: string;
|
|
333
|
-
direction: "in" | "out";
|
|
334
330
|
/** The medium as a person would say it, not an enum: "Zalo", "Google Meet". */
|
|
335
331
|
over: string;
|
|
336
332
|
when: string;
|
|
337
|
-
/**
|
|
338
|
-
* WHO put this here — and this feed has two kinds of author, so the type has
|
|
339
|
-
* two shapes. A person gets a `MemberChip`; an automation, an extraction or an
|
|
340
|
-
* agent gets `WaveAvatar`, the kit's mark for a non-human identity. Modelling
|
|
341
|
-
* both as one string forces the surface to give a recording bot a face and two
|
|
342
|
-
* initials, which is a small fabrication every reader notices and no test
|
|
343
|
-
* catches.
|
|
344
|
-
*/
|
|
345
|
-
by: { kind: "member"; name: string; image?: string } | { kind: "system"; name: string };
|
|
346
333
|
/** An unanswered outreach is a real state and reads differently from a reply. */
|
|
347
334
|
awaiting?: boolean;
|
|
348
335
|
|
|
@@ -370,15 +357,16 @@ interface ActivityEntry {
|
|
|
370
357
|
* Two rules, both learned by getting this wrong. It must be CAPTURABLE: a
|
|
371
358
|
* call's attendees are stored nowhere, so a line naming them can only be
|
|
372
359
|
* invented, and a block with no source teaches app authors to fabricate one.
|
|
373
|
-
* And it must be ADDITIVE: the record IS the counterparty,
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
* party — which is the entire value of the block.
|
|
360
|
+
* And it must be ADDITIVE: the record IS the counterparty, so "From <them>, to
|
|
361
|
+
* <us>" is a fact the surface already carries. What is left is the third party
|
|
362
|
+
* — which is the entire value of the block.
|
|
377
363
|
*/
|
|
378
364
|
participants?: string;
|
|
379
365
|
/** An email's subject — the one thing an email has that nothing else does. */
|
|
380
366
|
subject?: string;
|
|
381
|
-
/**
|
|
367
|
+
/** The message a COUNTERPARTY sent — an email body. Markdown, and read-only:
|
|
368
|
+
* it is a record of what they said, which is the same reason a transcript is
|
|
369
|
+
* not editable. Our own write-up of an entry is `gist`, which is a field. */
|
|
382
370
|
body?: string;
|
|
383
371
|
/** Prose a MODEL wrote. Separate from `body` rather than a flag on it, because
|
|
384
372
|
* one entry routinely holds both — a rep's own note and the machine's reading
|
|
@@ -459,10 +447,8 @@ const ACTIVITY: ActivityEntry[] = [
|
|
|
459
447
|
// Note the author: a bot, which is why `by` is not a name string.
|
|
460
448
|
key: "a0",
|
|
461
449
|
kind: "call",
|
|
462
|
-
direction: "in",
|
|
463
450
|
over: "Phone",
|
|
464
451
|
when: "Today, 11:40",
|
|
465
|
-
by: { kind: "system", name: "Recording bot" },
|
|
466
452
|
media: { src: "/sample-audio.mp3", label: "Call recording" },
|
|
467
453
|
transcript: CALL_TRANSCRIPT,
|
|
468
454
|
},
|
|
@@ -473,10 +459,8 @@ const ACTIVITY: ActivityEntry[] = [
|
|
|
473
459
|
key: "a3",
|
|
474
460
|
kind: "video",
|
|
475
461
|
gist: "Demo — the reconciliation step is what sold it; pricing still open",
|
|
476
|
-
direction: "out",
|
|
477
462
|
over: "Google Meet",
|
|
478
463
|
when: "Today, 10:15",
|
|
479
|
-
by: { kind: "member", name: "Sarah Chen" },
|
|
480
464
|
media: { src: "/sample-video.webm", label: "Demo recording" },
|
|
481
465
|
transcript: TRANSCRIPT,
|
|
482
466
|
// NO human note on this entry, deliberately. It carried "the person who
|
|
@@ -494,24 +478,21 @@ const ACTIVITY: ActivityEntry[] = [
|
|
|
494
478
|
key: "a1",
|
|
495
479
|
kind: "note",
|
|
496
480
|
gist: "They want the integration layer built and maintained, not the platform replaced",
|
|
497
|
-
direction: "out",
|
|
498
481
|
over: "Note",
|
|
499
482
|
when: "Today, 09:12",
|
|
500
|
-
by: { kind: "member", name: "Sarah Chen" },
|
|
501
483
|
},
|
|
502
484
|
{
|
|
503
485
|
key: "a1b",
|
|
504
486
|
kind: "note",
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
//
|
|
487
|
+
// A LONG one, to prove the entry grows with its prose instead of clamping it.
|
|
488
|
+
// This used to be stored twice — the same string as `gist` AND as `body` —
|
|
489
|
+
// on the argument that one was a truncation of the other. It was not: both
|
|
490
|
+
// rendered, one under the other, and the reader saw the sentence twice. The
|
|
491
|
+
// field auto-grows, so the long form IS the value and there is nothing to
|
|
492
|
+
// drill into.
|
|
509
493
|
gist: LONG_NOTE,
|
|
510
|
-
body: LONG_NOTE,
|
|
511
|
-
direction: "in",
|
|
512
494
|
over: "Site visit",
|
|
513
495
|
when: "Today, 08:05",
|
|
514
|
-
by: { kind: "member", name: "Sarah Chen" },
|
|
515
496
|
},
|
|
516
497
|
{
|
|
517
498
|
// AN EMAIL carries three things nothing else does: who it was between, what
|
|
@@ -523,10 +504,8 @@ const ACTIVITY: ActivityEntry[] = [
|
|
|
523
504
|
subject: "Re: Pricing for the customs documentation module",
|
|
524
505
|
participants: "Copied to accounts@",
|
|
525
506
|
gist: "Asked for the per-document price in writing before the board meeting",
|
|
526
|
-
direction: "in",
|
|
527
507
|
over: "Email",
|
|
528
508
|
when: "Yesterday, 16:40",
|
|
529
|
-
by: { kind: "system", name: "Mailbox sync" },
|
|
530
509
|
body:
|
|
531
510
|
"Thanks for the walkthrough. Before I take this to the board on Thursday I need the " +
|
|
532
511
|
"per-document price **in writing**, and confirmation that the customs forms come out of " +
|
|
@@ -542,10 +521,8 @@ const ACTIVITY: ActivityEntry[] = [
|
|
|
542
521
|
key: "a6",
|
|
543
522
|
kind: "message",
|
|
544
523
|
gist: "Asked in the forwarders' group who handles Japan customs paperwork",
|
|
545
|
-
direction: "in",
|
|
546
524
|
over: "Facebook Group",
|
|
547
525
|
when: "10 Aug, 21:30",
|
|
548
|
-
by: { kind: "system", name: "Capture" },
|
|
549
526
|
files: [
|
|
550
527
|
{ id: "s1", filename: "post.png", mimeType: "image/png", url: img("Post", "#3f3f46") },
|
|
551
528
|
{ id: "s2", filename: "profile.png", mimeType: "image/png", url: img("Profile", "#52525b") },
|
|
@@ -556,10 +533,8 @@ const ACTIVITY: ActivityEntry[] = [
|
|
|
556
533
|
key: "a4",
|
|
557
534
|
kind: "call",
|
|
558
535
|
gist: "34 minutes — locked into their current platform for six months, wants us to sit on top of it",
|
|
559
|
-
direction: "out",
|
|
560
536
|
over: "Phone",
|
|
561
537
|
when: "3 Aug, 11:20",
|
|
562
|
-
by: { kind: "member", name: "Sarah Chen" },
|
|
563
538
|
media: { src: "/sample-audio.mp3", label: "Call recording" },
|
|
564
539
|
transcript: CALL_TRANSCRIPT,
|
|
565
540
|
bodyByAi:
|
|
@@ -573,50 +548,13 @@ const ACTIVITY: ActivityEntry[] = [
|
|
|
573
548
|
key: "a5",
|
|
574
549
|
kind: "message",
|
|
575
550
|
gist: "Sent the one-page summary; no reply yet",
|
|
576
|
-
direction: "out",
|
|
577
551
|
over: "Zalo",
|
|
578
552
|
when: "28 Jul, 18:05",
|
|
579
|
-
by: { kind: "member", name: "Sarah Chen" },
|
|
580
553
|
awaiting: true,
|
|
581
554
|
files: [{ id: "f-sum", filename: "Summary — one page.pdf", mimeType: "application/pdf", url: "/sample.pdf" }],
|
|
582
555
|
},
|
|
583
556
|
];
|
|
584
557
|
|
|
585
|
-
/**
|
|
586
|
-
* What came WITH the entry, as a clause for the supporting line.
|
|
587
|
-
*
|
|
588
|
-
* A sentence rather than a row of glyphs: an icon run is compact but needs a key
|
|
589
|
-
* the reader has to learn before the first row means anything, and the line it
|
|
590
|
-
* would save already exists.
|
|
591
|
-
*
|
|
592
|
-
* ONLY on an entry with no gist. There, the attachments ARE the content — "not
|
|
593
|
-
* written up yet" is useless without "there is a recording to write it up from".
|
|
594
|
-
* Anywhere else it repeats what the body shows one press away, and it is not
|
|
595
|
-
* free: measured at 375 the supporting line already fills the two lines
|
|
596
|
-
* `Timeline` clamps it at, so an unconditional clause clips the date it was
|
|
597
|
-
* appended to. The clause APPEARING is therefore itself the signal that the row
|
|
598
|
-
* is otherwise empty.
|
|
599
|
-
*/
|
|
600
|
-
function arrivedWith(a: ActivityEntry): string {
|
|
601
|
-
if (a.gist) return "";
|
|
602
|
-
// Name the thing, not its relation to the sentence: "Attachment attached" is
|
|
603
|
-
// the shape a templated clause produces and it reads as a stutter.
|
|
604
|
-
// Lower case throughout, capitalised once at the end — joining already-capital
|
|
605
|
-
// words gave "Recording, Transcript and file attached." the moment a third
|
|
606
|
-
// artifact appeared, which no fixture reached and every real row eventually
|
|
607
|
-
// will. The type guard rather than `filter(Boolean)` is what removes the `!`
|
|
608
|
-
// on the last element: `Boolean` does not narrow.
|
|
609
|
-
const parts = [
|
|
610
|
-
a.media ? "recording" : null,
|
|
611
|
-
a.transcript ? "transcript" : null,
|
|
612
|
-
a.files?.length ? "file" : null,
|
|
613
|
-
].filter((x): x is string => x !== null);
|
|
614
|
-
if (parts.length === 0) return "";
|
|
615
|
-
const list =
|
|
616
|
-
parts.length === 1 ? parts[0] : `${parts.slice(0, -1).join(", ")} and ${parts.at(-1)}`;
|
|
617
|
-
return ` ${list[0].toUpperCase()}${list.slice(1)} attached.`;
|
|
618
|
-
}
|
|
619
|
-
|
|
620
558
|
/**
|
|
621
559
|
* THE ACTIVITY BODY — one component, every block, each rendered only if the
|
|
622
560
|
* entry carries it.
|
|
@@ -628,60 +566,201 @@ function arrivedWith(a: ActivityEntry): string {
|
|
|
628
566
|
* source, then what people made of it, then what came with it, then where it
|
|
629
567
|
* came from and who filed it.
|
|
630
568
|
*/
|
|
631
|
-
|
|
569
|
+
/**
|
|
570
|
+
* ONE ENTRY in the activity feed — a comment, not a log line.
|
|
571
|
+
*
|
|
572
|
+
* `Timeline` renders its label INSIDE the row's press target. That is right for
|
|
573
|
+
* an event whose text is derived and read-only ("Stage changed to Won"), and
|
|
574
|
+
* wrong here, because the gist is the one thing on an entry a PERSON wrote, and
|
|
575
|
+
* text inside a button cannot be edited where it sits. Four repairs came out of
|
|
576
|
+
* working around that — the verbs moved off the row, the label learned to
|
|
577
|
+
* un-clamp, the body's second copy of the gist came out, and the Edit verb ended
|
|
578
|
+
* up a scroll away from the sentence it edits. None were independent: each was
|
|
579
|
+
* the arrangement pushing back.
|
|
580
|
+
*
|
|
581
|
+
* So the gist stops being a label. It is a RESIDENT field, always editable in
|
|
582
|
+
* place, with no mode and no Edit verb — the same treatment every other authored
|
|
583
|
+
* value on this record gets. The old argument against a resident editor here
|
|
584
|
+
* ("the gist is already the row's label, so it renders the sentence twice")
|
|
585
|
+
* disappears the moment it stops being one.
|
|
586
|
+
*/
|
|
587
|
+
function ActivityEntryRow({
|
|
632
588
|
a,
|
|
633
|
-
editing,
|
|
634
589
|
onEdit,
|
|
635
|
-
onToggleEdit,
|
|
636
590
|
onDelete,
|
|
637
591
|
}: {
|
|
638
592
|
a: ActivityEntry;
|
|
639
|
-
|
|
640
|
-
* here rather than the resident editors every other section uses. */
|
|
641
|
-
editing: boolean;
|
|
642
|
-
onEdit: (patch: Partial<Pick<ActivityEntry, "gist" | "body">>) => void;
|
|
643
|
-
onToggleEdit: () => void;
|
|
593
|
+
onEdit: (patch: Partial<Pick<ActivityEntry, "gist" | "files">>) => void;
|
|
644
594
|
onDelete: () => void;
|
|
645
595
|
}) {
|
|
646
596
|
const [showTranscript, setShowTranscript] = useState(false);
|
|
647
|
-
const
|
|
648
|
-
|
|
597
|
+
const files = a.files ?? [];
|
|
598
|
+
// ONE function behind every way in — the menu verb, and a drop onto the entry.
|
|
599
|
+
// Two paths that append to the same list must not be two implementations of
|
|
600
|
+
// appending: that is where one of them ends up REPLACING instead, silently,
|
|
601
|
+
// months later.
|
|
602
|
+
const addFiles = (picked: File[]) => {
|
|
603
|
+
if (picked.length === 0) return;
|
|
604
|
+
onEdit({ files: [...files, ...picked.map(asDisplayFile)] });
|
|
605
|
+
};
|
|
606
|
+
// The menu verb exists because it has to work on an entry that has NO files
|
|
607
|
+
// yet — that entry has no list to hang a CTA off, and no obvious place to aim
|
|
608
|
+
// a drag either.
|
|
609
|
+
const attach = () => { void pickFiles({ accept: ATTACH_ACCEPT, multiple: true }).then(addFiles); };
|
|
610
|
+
const accent = a.awaiting ? solid("amber") : colors.zinc[400];
|
|
611
|
+
// What the fold contains, named so the reader can decide without opening it.
|
|
612
|
+
// NOT `arrivedWith` — that answers a different question (it speaks only when
|
|
613
|
+
// the entry has no gist, because there the attachments are the whole content)
|
|
614
|
+
// and reusing it here left the fold unrendered, and the recording unreachable,
|
|
615
|
+
// on every entry somebody had written up.
|
|
649
616
|
|
|
650
617
|
return (
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
618
|
+
/* AN ENTRY IS ITS OWN DROP REGION — a file dropped on THIS conversation
|
|
619
|
+
attaches to it, rather than travelling to the record's Documents intake
|
|
620
|
+
and leaving the reader to relate the two by hand.
|
|
621
|
+
|
|
622
|
+
Nesting inside the whole-record target is sound rather than lucky:
|
|
623
|
+
`FileDropTarget` stops propagation on every drag/drop event, so the
|
|
624
|
+
innermost region wins and the outer one neither fires nor lights. The
|
|
625
|
+
target paints its own affordance from a plain child, which is what makes
|
|
626
|
+
the destination legible mid-drag — with two live regions the drag has to
|
|
627
|
+
say WHICH one it is about to land in. */
|
|
628
|
+
<FileDropTarget onFiles={addFiles} accept={ATTACH_ACCEPT} style={{ flexDirection: "row", gap: 12 }}>
|
|
629
|
+
{/* The medium as a glyph, replacing the disc and spine. A feed of ENTRIES
|
|
630
|
+
reads as a stack; a spine drawn past an editable field reads as
|
|
631
|
+
decoration rather than as sequence. */}
|
|
632
|
+
<View
|
|
633
|
+
style={{
|
|
634
|
+
width: 32,
|
|
635
|
+
height: 32,
|
|
636
|
+
borderRadius: 16,
|
|
637
|
+
alignItems: "center",
|
|
638
|
+
justifyContent: "center",
|
|
639
|
+
backgroundColor: withAlpha(accent, 0.1),
|
|
640
|
+
}}
|
|
641
|
+
>
|
|
642
|
+
<Icon name={ACTIVITY_ICON[a.kind]} size={16} color={accent} />
|
|
643
|
+
</View>
|
|
644
|
+
|
|
645
|
+
<View style={{ flex: 1, gap: 8 }}>
|
|
646
|
+
{/* THE BYLINE — over what, and when. Derived, so none of it is editable,
|
|
647
|
+
and none of it repeats the prose below.
|
|
648
|
+
|
|
649
|
+
NO DIRECTION. "From us over Note" is a category error — a note has no
|
|
650
|
+
counterparty to be from — and the shape that produces it is the tell:
|
|
651
|
+
direction was being stamped on every entry whether or not the medium
|
|
652
|
+
had two ends. On the media that do have two ends it is real but
|
|
653
|
+
already said twice over: its one ACTIONABLE consequence (we reached
|
|
654
|
+
out, nobody answered) is the amber `awaiting` mark on the glyph, and
|
|
655
|
+
the rest is history the gist states in words — "Sent the one-page
|
|
656
|
+
summary", "They want the integration layer built". Eight characters
|
|
657
|
+
of chrome down the whole column to restate the sentence beneath.
|
|
658
|
+
|
|
659
|
+
NO AUTHOR. It carried one for a while, in two shapes: a face for a
|
|
660
|
+
person, `WaveAvatar` for an automation. Both are gone, and the reason
|
|
661
|
+
the machine one was wrong is the reason the human one had to go with
|
|
662
|
+
it. A recording is MADE by the people in the meeting and merely
|
|
663
|
+
transcribed by a service, so "Recording bot" named the plumbing and
|
|
664
|
+
not the author — a fabricated byline on somebody else's conversation.
|
|
665
|
+
Once that shape is refused, showing an author on the entries that
|
|
666
|
+
happen to have one is worse than showing none: the reader learns that
|
|
667
|
+
the byline is present when we know it, which is a fact about our
|
|
668
|
+
pipeline. The record has an Owner, and an entry on a customer's
|
|
669
|
+
history does not restate it eight times.
|
|
670
|
+
|
|
671
|
+
What a machine actually TOUCHED is still visible where it changes how
|
|
672
|
+
you read something — the fold names an "AI summary", and a transcript
|
|
673
|
+
is labelled verbatim. That is attribution of the CONTENT, which is
|
|
674
|
+
the only kind that was ever load-bearing here.
|
|
675
|
+
|
|
676
|
+
The actions sit at the TOP RIGHT rather than a footer — a footer put
|
|
677
|
+
Edit a scroll away from the sentence it edits. The menu is a SIBLING
|
|
678
|
+
of the content, so it nests no button inside another. */}
|
|
679
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
680
|
+
<View style={{ flex: 1, flexDirection: "row", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
|
681
|
+
<Text size="xs" color="muted">
|
|
682
|
+
{`${a.over}, ${a.when}`}
|
|
683
|
+
</Text>
|
|
684
|
+
{a.sourceUrl ? <Link size="xs" onPress={() => {}}>{a.sourceUrl}</Link> : null}
|
|
685
|
+
</View>
|
|
686
|
+
<ActionMenu
|
|
687
|
+
accessibilityLabel="Entry actions"
|
|
688
|
+
items={[
|
|
689
|
+
{
|
|
690
|
+
key: "attach",
|
|
691
|
+
label: "Attach files",
|
|
692
|
+
icon: "paperclip",
|
|
693
|
+
onPress: attach,
|
|
694
|
+
},
|
|
695
|
+
{
|
|
696
|
+
key: "delete",
|
|
697
|
+
label: "Delete",
|
|
698
|
+
icon: "trash",
|
|
699
|
+
danger: true,
|
|
700
|
+
onPress: () =>
|
|
701
|
+
Alert.alert(
|
|
702
|
+
"Delete this entry?",
|
|
703
|
+
"It leaves the customer's history and stops counting toward the activity total. This cannot be undone.",
|
|
704
|
+
[
|
|
705
|
+
{ text: "Cancel", style: "cancel" },
|
|
706
|
+
{ text: "Delete", style: "destructive", onPress: onDelete },
|
|
707
|
+
],
|
|
708
|
+
),
|
|
709
|
+
},
|
|
710
|
+
]}
|
|
711
|
+
/>
|
|
712
|
+
</View>
|
|
713
|
+
|
|
714
|
+
{/* THE WORDS A PERSON WROTE — resident, always editable in place. An
|
|
715
|
+
entry nobody has written up shows the field and invites the sentence;
|
|
716
|
+
one that has been written up simply reads. That empty case is the
|
|
717
|
+
common one on a feed more than one writer fills: an automation files
|
|
718
|
+
an entry the moment a recording lands, with no words in it at all. */}
|
|
719
|
+
{/* THE VARIANT FOLLOWS THE VALUE, because the two states are asking for
|
|
720
|
+
different things. Written up, this is CONTENT: `bare`, reading as
|
|
721
|
+
prose, editable where it sits. Empty, it is a REQUEST for content —
|
|
722
|
+
and bare grey placeholder text on an invisible box does not read as
|
|
723
|
+
somewhere you can type, which is exactly the complaint. `framed`
|
|
724
|
+
gives it the resting border every other empty field on the record
|
|
725
|
+
has, so it is recognisable as an input from across the page.
|
|
726
|
+
|
|
727
|
+
A button ("Add a summary") was the other candidate and loses on the
|
|
728
|
+
click: `InlineTextInput` deliberately has no `autoFocus` (mounted
|
|
729
|
+
permanently it would have every field on a record fight for focus on
|
|
730
|
+
load), so a revealed field cannot take the caret — press the button,
|
|
731
|
+
then click the field, then type. The framed box is one click, and it
|
|
732
|
+
spends its extra ink only on the entries that actually want words. */}
|
|
676
733
|
<InlineTextInput
|
|
734
|
+
variant={a.gist ? "bare" : "framed"}
|
|
677
735
|
value={a.gist ?? ""}
|
|
678
736
|
onSave={(v) => onEdit({ gist: v.trim() || undefined })}
|
|
679
737
|
placeholder="What came of it?"
|
|
680
738
|
accessibilityLabel="What came of it"
|
|
681
|
-
|
|
739
|
+
// ONE line is the floor, not two. `autoGrow` takes care of the ceiling,
|
|
740
|
+
// so a minimum of 2 bought nothing and cost an empty second line on
|
|
741
|
+
// every entry nobody had written up — which is most of them on a feed
|
|
742
|
+
// an automation also files into, and precisely what made a stack of
|
|
743
|
+
// entries read as a stack of form fields.
|
|
744
|
+
numberOfLines={1}
|
|
682
745
|
autoGrow
|
|
683
746
|
/>
|
|
684
|
-
|
|
747
|
+
|
|
748
|
+
{/* WHAT CAME WITH IT — SHOWN, not folded. These blocks ARE the entry;
|
|
749
|
+
a control that hides them makes the reader click to discover what the
|
|
750
|
+
entry already told them is there, on every entry, forever.
|
|
751
|
+
|
|
752
|
+
The fold was defended as "each is tall enough to bury the next
|
|
753
|
+
entry", which measurement did not support: an audio player is 54px,
|
|
754
|
+
a file tile row is 96. Hiding 54px behind a 20px disclosure buys 34px
|
|
755
|
+
and costs a click. Only two things here are genuinely unbounded, and
|
|
756
|
+
only ONE of them stays folded — see the transcript below.
|
|
757
|
+
|
|
758
|
+
A video is the honest counter-case at 358px, and it still shows: it
|
|
759
|
+
is the single most informative thing on a recorded call, the feed
|
|
760
|
+
already folds its own tail, and the byline and gist stay at the top
|
|
761
|
+
of every entry so the scan survives a tall one. */}
|
|
762
|
+
<View style={{ gap: 12, alignSelf: "stretch" }}>
|
|
763
|
+
|
|
685
764
|
|
|
686
765
|
{/* HEADER — who it was between, and what it was called. A subject line
|
|
687
766
|
never says who was on it, and on a call there is no subject at all, so
|
|
@@ -739,26 +818,16 @@ function ActivityBody({
|
|
|
739
818
|
</View>
|
|
740
819
|
) : null}
|
|
741
820
|
|
|
742
|
-
{/*
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
) : editing ? (
|
|
753
|
-
<InlineTextInput
|
|
754
|
-
value={a.body ?? ""}
|
|
755
|
-
onSave={(v) => onEdit({ body: v.trim() || undefined })}
|
|
756
|
-
placeholder="Add a note…"
|
|
757
|
-
accessibilityLabel="Note"
|
|
758
|
-
numberOfLines={2}
|
|
759
|
-
autoGrow
|
|
760
|
-
/>
|
|
761
|
-
) : a.body ? (
|
|
821
|
+
{/* THE COUNTERPARTY'S OWN WORDS, on an email. It is a record of what THEY
|
|
822
|
+
sent, so it renders as markdown and is not ours to rewrite — the same
|
|
823
|
+
reason the transcript is not editable.
|
|
824
|
+
|
|
825
|
+
There is NO second prose field beside it. An entry used to offer a
|
|
826
|
+
gist AND a "note", two free-text boxes for one event with nothing to
|
|
827
|
+
tell a writer which to use; the gist auto-grows, so it already carries
|
|
828
|
+
whatever length someone wants. Two fields for one thought is a choice
|
|
829
|
+
the surface was making the reader make. */}
|
|
830
|
+
{a.kind === "email" && a.body ? (
|
|
762
831
|
<Markdown variant="embedded">{a.body}</Markdown>
|
|
763
832
|
) : null}
|
|
764
833
|
|
|
@@ -786,90 +855,53 @@ function ActivityBody({
|
|
|
786
855
|
</Inset>
|
|
787
856
|
) : null}
|
|
788
857
|
|
|
789
|
-
{/* WHAT CAME WITH IT
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
the
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
They are ordinary `Button`s, not the quiet text controls used inside
|
|
824
|
-
prose above: these MUTATE, so they carry a control surface. Right-
|
|
825
|
-
aligned in a footer band, they establish no text edge to betray, which
|
|
826
|
-
is the condition the fill-less rungs need. */}
|
|
827
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
|
|
828
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
829
|
-
<Text size="xs" color="muted">Logged by</Text>
|
|
830
|
-
{a.by.kind === "member" ? (
|
|
831
|
-
<MemberChip name={a.by.name} image={a.by.image} size="sm" />
|
|
832
|
-
) : (
|
|
833
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
834
|
-
<WaveAvatar size={24} />
|
|
835
|
-
<Text size="sm">{a.by.name}</Text>
|
|
836
|
-
</View>
|
|
837
|
-
)}
|
|
838
|
-
</View>
|
|
839
|
-
{a.sourceUrl ? (
|
|
840
|
-
<Link size="xs" onPress={() => {}}>{a.sourceUrl}</Link>
|
|
841
|
-
) : null}
|
|
842
|
-
{/* Pushes the verbs to the far edge, so provenance and actions are two
|
|
843
|
-
groups rather than a run of four items on one gap. */}
|
|
844
|
-
<View style={{ flex: 1, minWidth: 24 }} />
|
|
845
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
846
|
-
<Button
|
|
847
|
-
title={editing ? "Done" : "Edit"}
|
|
848
|
-
color="muted"
|
|
849
|
-
onPress={onToggleEdit}
|
|
850
|
-
/>
|
|
851
|
-
<Button
|
|
852
|
-
title="Delete"
|
|
853
|
-
color="danger-secondary"
|
|
854
|
-
onPress={() =>
|
|
855
|
-
Alert.alert(
|
|
856
|
-
"Delete this entry?",
|
|
857
|
-
"It leaves the customer's history and stops counting toward the activity total. This cannot be undone.",
|
|
858
|
-
[
|
|
859
|
-
{ text: "Cancel", style: "cancel" },
|
|
860
|
-
{ text: "Delete", style: "destructive", onPress: onDelete },
|
|
861
|
-
],
|
|
862
|
-
)
|
|
863
|
-
}
|
|
864
|
-
/>
|
|
865
|
-
</View>
|
|
858
|
+
{/* WHAT CAME WITH IT — a GRID, the same as a posted chat message's
|
|
859
|
+
attachments and the same as this record's own Files section.
|
|
860
|
+
|
|
861
|
+
It was a row list for a while, on a rule I got wrong: "staged files are
|
|
862
|
+
tiles, filed files are rows". The rule held up only against the fee
|
|
863
|
+
ledger's "Supplier original", which is a labelled FIELD in a
|
|
864
|
+
`DetailRow` — a different thing from the attachments on a message. The
|
|
865
|
+
two real precedents both disagreed with me: `chat_user_message` renders
|
|
866
|
+
a posted message's files as a `FileGrid`, and so does the record's own
|
|
867
|
+
Files section. An entry's files are evidence — a scanned PO, a photo of
|
|
868
|
+
a seal — recognised by looking, which is what a tile is for.
|
|
869
|
+
|
|
870
|
+
`FilesEditor`, not a bare `FileGrid`, because a filed attachment must be
|
|
871
|
+
HARD to lose. The grid's ✕ sits on every tile, one click from gone; the
|
|
872
|
+
editor's default view never draws one at all, and the only way to
|
|
873
|
+
remove is to open the file and do it from the gallery — where you are
|
|
874
|
+
looking at the thing you are about to delete, and it still asks. Three
|
|
875
|
+
pieces I had hand-rolled on top of `FileGrid` come with it: the
|
|
876
|
+
confirmation, the full-screen gallery, and the press-to-open wiring.
|
|
877
|
+
|
|
878
|
+
NO BAR. `FilesEditor` renders its children as an action row and nothing
|
|
879
|
+
without them, so a per-entry Select/Remove toolbar is opt-in — and a
|
|
880
|
+
feed of eight entries does not want eight toolbars. Select mode is
|
|
881
|
+
reachable only from that bar, so leaving it out removes the mode
|
|
882
|
+
entirely rather than stranding it. Adding stays on the entry's menu, so
|
|
883
|
+
there is still exactly one add path. */}
|
|
884
|
+
{files.length ? (
|
|
885
|
+
<FilesEditor
|
|
886
|
+
files={files}
|
|
887
|
+
itemSize={THUMBNAIL_SIZE}
|
|
888
|
+
onRemove={(id) => onEdit({ files: files.filter((x) => x.id !== id) })}
|
|
889
|
+
/>
|
|
890
|
+
) : null}
|
|
891
|
+
|
|
866
892
|
</View>
|
|
867
|
-
|
|
893
|
+
</View>
|
|
894
|
+
</FileDropTarget>
|
|
868
895
|
);
|
|
869
896
|
}
|
|
870
897
|
|
|
871
898
|
/** The glyph per medium. A medium is a CATEGORY, so it rides the icon and the
|
|
872
899
|
* supporting line — never a `Badge`, which this kit reserves for status. */
|
|
900
|
+
// What a touchpoint can carry, in ONE place: the entry's menu, a drop onto the
|
|
901
|
+
// entry and the composer's attach button all filter identically, because a file
|
|
902
|
+
// the drag accepts and the picker refuses is a difference nobody can see.
|
|
903
|
+
const ATTACH_ACCEPT = "application/pdf,image/*";
|
|
904
|
+
|
|
873
905
|
const ACTIVITY_ICON: Record<ActivityKind, IconName> = {
|
|
874
906
|
call: "phone",
|
|
875
907
|
video: "monitor",
|
|
@@ -1834,6 +1866,18 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
1834
1866
|
// ── activity — the feed is state so a logged note appears at the top of it,
|
|
1835
1867
|
// where it happened, rather than after a refetch.
|
|
1836
1868
|
const [activity, setActivity] = useState<ActivityEntry[]>(ACTIVITY);
|
|
1869
|
+
// Files staged on the NEXT entry — the feed's own queue, not the comment
|
|
1870
|
+
// thread's. Sharing one would let a file picked for a touchpoint ride out on a
|
|
1871
|
+
// comment instead, which is a data error with no visible symptom: both
|
|
1872
|
+
// surfaces would look correct and the evidence would be filed against the
|
|
1873
|
+
// wrong thing. A product swaps these two lines for its upload queue.
|
|
1874
|
+
const [pendingActivityFiles, setPendingActivityFiles] = useState<DisplayFile[]>([]);
|
|
1875
|
+
const attachToActivity = () => {
|
|
1876
|
+
void pickFiles({ accept: ATTACH_ACCEPT, multiple: true }).then((chosen) => {
|
|
1877
|
+
if (chosen.length === 0) return;
|
|
1878
|
+
setPendingActivityFiles((prev) => [...prev, ...chosen.map(asDisplayFile)]);
|
|
1879
|
+
});
|
|
1880
|
+
};
|
|
1837
1881
|
// A record's feed is not a register: you do not PAGE it, you fold the tail.
|
|
1838
1882
|
// The recent entries are what the reader came for, and an unbounded feed makes
|
|
1839
1883
|
// every other section on the record unreachable by scroll.
|
|
@@ -1842,7 +1886,6 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
1842
1886
|
// ONE entry at a time. A feed with several rows open in edit mode gives the
|
|
1843
1887
|
// reader two half-finished sentences and no way to tell which one the next
|
|
1844
1888
|
// keystroke lands in.
|
|
1845
|
-
const [editingActivity, setEditingActivity] = useState<string | null>(null);
|
|
1846
1889
|
|
|
1847
1890
|
// ── the record — seeded mid-flight so every state is visible
|
|
1848
1891
|
const [stage, setStage] = useState<Stage>("sales");
|
|
@@ -2659,7 +2702,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
2659
2702
|
renderFiles={(files) => (
|
|
2660
2703
|
<FileGrid
|
|
2661
2704
|
files={files.map(commentFileToDisplay)}
|
|
2662
|
-
itemSize={
|
|
2705
|
+
itemSize={THUMBNAIL_SIZE}
|
|
2663
2706
|
onFilePress={(f) => setPreview({ files: [f], index: 0 })}
|
|
2664
2707
|
/>
|
|
2665
2708
|
)}
|
|
@@ -2677,9 +2720,18 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
2677
2720
|
}}
|
|
2678
2721
|
placeholder="Write a comment…"
|
|
2679
2722
|
sendLabel="Send"
|
|
2680
|
-
actionsButton={<IconButton size="lg" color="secondary" icon="
|
|
2723
|
+
actionsButton={<IconButton size="lg" color="secondary" icon="paperclip" tooltip="Attach files" onPress={attachToComment} />}
|
|
2724
|
+
/* Same reason as the activity composer above: a comment that is purely
|
|
2725
|
+
an attachment is an ordinary thing to send, and the default
|
|
2726
|
+
block-on-empty-text refused it. */
|
|
2727
|
+
sendDisabled={pendingFiles.length > 0 ? false : undefined}
|
|
2728
|
+
/* Same surface as the activity composer above, and as chat. */
|
|
2681
2729
|
files={pendingFiles.length > 0 ? (
|
|
2682
|
-
<
|
|
2730
|
+
<FileGrid
|
|
2731
|
+
files={pendingFiles.map(commentFileToDisplay)}
|
|
2732
|
+
itemSize={THUMBNAIL_SIZE}
|
|
2733
|
+
onDisplayRemove={(id) => setPendingFiles((prev) => prev.filter((x) => x.id !== id))}
|
|
2734
|
+
/>
|
|
2683
2735
|
) : undefined}
|
|
2684
2736
|
/>
|
|
2685
2737
|
</>
|
|
@@ -2977,21 +3029,50 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
2977
3029
|
placeholder="Log a call, an email, a note…"
|
|
2978
3030
|
accessibilityLabel="Log an activity"
|
|
2979
3031
|
sendLabel="Log"
|
|
3032
|
+
/* A touchpoint is very often ABOUT a document — you sent the quote,
|
|
3033
|
+
they sent the PO back — and `ActivityEntry` has carried `files`
|
|
3034
|
+
all along; only the write path was missing, so the feed could
|
|
3035
|
+
display evidence it gave nobody a way to file. The comment
|
|
3036
|
+
composer below already wires exactly this, from the same three
|
|
3037
|
+
kit slots; this one simply did not. */
|
|
3038
|
+
actionsButton={<IconButton size="lg" color="secondary" icon="paperclip" tooltip="Attach files" onPress={attachToActivity} />}
|
|
3039
|
+
/* THE SAME COMPONENT AND SIZE AS CHAT — `FileGrid` at `THUMBNAIL_SIZE`,
|
|
3040
|
+
which is what the product's own composer passes. Two things were
|
|
3041
|
+
wrong with hand-rolling a row of `FileThumbnail` here: it
|
|
3042
|
+
re-implements a component that exists (so it silently lacks the
|
|
3043
|
+
overflow, selection and upload states `FileGrid` carries, and it
|
|
3044
|
+
teaches app authors to hand-roll a grid), and it sized the tiles
|
|
3045
|
+
with a magic 56 copied off a demo file while the kit EXPORTS the
|
|
3046
|
+
size a composer attachment is. A staged file is checked by LOOKING
|
|
3047
|
+
at it; 56px of a scanned PO shows nothing. Rows are for a filed
|
|
3048
|
+
set, which is what an entry's own list is. */
|
|
3049
|
+
files={pendingActivityFiles.length > 0 ? (
|
|
3050
|
+
<FileGrid
|
|
3051
|
+
files={pendingActivityFiles}
|
|
3052
|
+
itemSize={THUMBNAIL_SIZE}
|
|
3053
|
+
onDisplayRemove={(id) => setPendingActivityFiles((prev) => prev.filter((x) => x.id !== id))}
|
|
3054
|
+
/>
|
|
3055
|
+
) : undefined}
|
|
3056
|
+
/* STAGED FILES ARE CONTENT. The composer blocks send on empty text by
|
|
3057
|
+
default, which is right for a message and wrong the moment an
|
|
3058
|
+
attachment is the whole point — "here is the signed PO" needs no
|
|
3059
|
+
sentence. `undefined` keeps the default when nothing is staged. */
|
|
3060
|
+
sendDisabled={pendingActivityFiles.length > 0 ? false : undefined}
|
|
2980
3061
|
onSend={(text) => {
|
|
2981
3062
|
const t = text.trim();
|
|
2982
|
-
if (!t) return;
|
|
3063
|
+
if (!t && pendingActivityFiles.length === 0) return;
|
|
2983
3064
|
setActivity((prev) => [
|
|
2984
3065
|
{
|
|
2985
3066
|
key: `a-${prev.length + 1}-${t.length}`,
|
|
2986
3067
|
kind: "note",
|
|
2987
|
-
gist: t,
|
|
2988
|
-
direction: "out",
|
|
3068
|
+
gist: t || undefined,
|
|
2989
3069
|
over: "Note",
|
|
2990
3070
|
when: "Just now",
|
|
2991
|
-
|
|
3071
|
+
files: pendingActivityFiles.length > 0 ? pendingActivityFiles : undefined,
|
|
2992
3072
|
},
|
|
2993
3073
|
...prev,
|
|
2994
3074
|
]);
|
|
3075
|
+
setPendingActivityFiles([]);
|
|
2995
3076
|
}}
|
|
2996
3077
|
/>
|
|
2997
3078
|
|
|
@@ -3003,56 +3084,45 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
3003
3084
|
/>
|
|
3004
3085
|
) : (
|
|
3005
3086
|
<>
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
setEditingActivity((cur) => (cur === a.key ? null : a.key))
|
|
3046
|
-
}
|
|
3047
|
-
onDelete={() => {
|
|
3048
|
-
setActivity((prev) => prev.filter((e) => e.key !== a.key));
|
|
3049
|
-
setEditingActivity((cur) => (cur === a.key ? null : cur));
|
|
3050
|
-
}}
|
|
3051
|
-
/>
|
|
3052
|
-
),
|
|
3053
|
-
}),
|
|
3054
|
-
)}
|
|
3055
|
-
/>
|
|
3087
|
+
{/* AN ENTRY, NOT A LOG LINE — and that distinction is the whole
|
|
3088
|
+
reason this stopped being a `Timeline`.
|
|
3089
|
+
|
|
3090
|
+
`Timeline` puts its label INSIDE the row's press target. That
|
|
3091
|
+
is right for an event ("Stage changed to Won") whose text is
|
|
3092
|
+
derived and read-only. It is wrong here, because the gist is
|
|
3093
|
+
the one thing on an entry a person WROTE, and text inside a
|
|
3094
|
+
button can never be edited where it sits. Four repairs came out
|
|
3095
|
+
of working around that — the verbs moved off the row, the label
|
|
3096
|
+
learned to un-clamp, the body's copy of the gist came out, and
|
|
3097
|
+
the Edit verb ended up a scroll away from the sentence it
|
|
3098
|
+
edits. They were never independent: each one was the
|
|
3099
|
+
arrangement pushing back.
|
|
3100
|
+
|
|
3101
|
+
So the gist is not a label. It is a RESIDENT field — always
|
|
3102
|
+
editable, in place, no mode and no Edit button, the same as
|
|
3103
|
+
every other authored value on this record. The old argument
|
|
3104
|
+
against a resident editor here ("the gist is already the row's
|
|
3105
|
+
label, so it renders the sentence twice") disappears the moment
|
|
3106
|
+
it stops being one.
|
|
3107
|
+
|
|
3108
|
+
What is left is the shape every comment UI converged on: a
|
|
3109
|
+
metadata header, the prose under it, the row's own actions at
|
|
3110
|
+
the TOP RIGHT beside the text they act on, and the heavy
|
|
3111
|
+
artifacts behind their own disclosure. */}
|
|
3112
|
+
<View style={{ gap: 20 }}>
|
|
3113
|
+
{(showAllActivity ? activity : activity.slice(0, ACTIVITY_FOLD)).map((a) => (
|
|
3114
|
+
<ActivityEntryRow
|
|
3115
|
+
key={a.key}
|
|
3116
|
+
a={a}
|
|
3117
|
+
onEdit={(patch) =>
|
|
3118
|
+
setActivity((prev) =>
|
|
3119
|
+
prev.map((e) => (e.key === a.key ? { ...e, ...patch } : e)),
|
|
3120
|
+
)
|
|
3121
|
+
}
|
|
3122
|
+
onDelete={() => setActivity((prev) => prev.filter((e) => e.key !== a.key))}
|
|
3123
|
+
/>
|
|
3124
|
+
))}
|
|
3125
|
+
</View>
|
|
3056
3126
|
{/* The tail FOLDS rather than paging. A record's feed is read
|
|
3057
3127
|
newest-first and the old entries are reference — but they are
|
|
3058
3128
|
still on the record, so the count says how much is behind the
|
|
@@ -3486,8 +3556,15 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418", openSection
|
|
|
3486
3556
|
{/* DOCUMENTS — the document desk: the register Table (search, Create
|
|
3487
3557
|
documents, Add files) whose selection feeds the Use-AI fork below.
|
|
3488
3558
|
No FileDropTarget of its OWN — the WHOLE-RECORD target above already
|
|
3489
|
-
captures a drop/paste here
|
|
3490
|
-
|
|
3559
|
+
captures a drop/paste here; this section keeps only its explicit
|
|
3560
|
+
Add-files CTA and the affordance line.
|
|
3561
|
+
|
|
3562
|
+
The record-level target is the FALLBACK, not the only one. An
|
|
3563
|
+
activity entry nests its own, because a file dropped on a
|
|
3564
|
+
conversation belongs to that conversation — and `FileDropTarget`
|
|
3565
|
+
stops propagation, so the innermost region wins and this one never
|
|
3566
|
+
sees it. A section adds its own only when the drop MEANS something
|
|
3567
|
+
different there; "the same thing, closer" is not a reason. */}
|
|
3491
3568
|
<View onLayout={nav.register("files")}>
|
|
3492
3569
|
<Section>
|
|
3493
3570
|
<SectionHeading>
|