Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions web/src/app/(shell)/conversations/[id]/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { Skeleton } from '../../../../components/ds/core/Skeleton'
import { ToolbarBand } from '../../../../components/ds/inbox/ToolbarBand'

/**
* Conversation-view loading state — the fidelity checklist's last §5 gap.
* Mirrors the inbox loading skeleton's approach (bare ds primitives, no real
* data) but shaped like THIS screen's actual layout: a toolbar band, the
* subject row, a run of message-band placeholders, and the Customer context
* panel — so the swap from skeleton to real content doesn't jump the eye
* around. Like `inbox/[folder]/loading.tsx`, the `(shell)` layout (folder
* rail, top bar) stays mounted across the navigation; only this work surface
* is replaced while the conversation fetches.
*/
function SkeletonMessageBand({ sameSpeakerAsPrev = false }: { sameSpeakerAsPrev?: boolean }) {
return (
<div
style={{
display: 'flex',
gap: 12,
padding: '14px 18px',
borderTop: sameSpeakerAsPrev ? '1px solid var(--ht-divider)' : 'none',
}}
>
<Skeleton width={32} height={32} radius={999} />
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10 }}>
<Skeleton width={90} height={12} />
<Skeleton width={140} height={11} />
</div>
<Skeleton width="85%" height={11} />
<Skeleton width="60%" height={11} />
</div>
</div>
)
}

export default function ConversationLoading() {
return (
<div style={{ flex: 1, minWidth: 0, display: 'flex', minHeight: 0 }}>
<main
style={{
flex: 1,
minWidth: 0,
background: 'var(--ht-surface)',
boxShadow: 'var(--ht-seam-shadow, -1px 0 0 var(--ht-divider))',
display: 'flex',
flexDirection: 'column',
minHeight: 0,
}}
>
<ToolbarBand />

<div
style={{
padding: '14px 18px',
display: 'flex',
alignItems: 'center',
gap: 10,
}}
>
<Skeleton width="35%" height={19} />
<Skeleton width={64} height={20} radius={999} />
</div>

<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
<SkeletonMessageBand />
<SkeletonMessageBand />
<SkeletonMessageBand sameSpeakerAsPrev />
</div>
</main>

<aside
aria-label="Conversation details"
style={{
width: 240,
flexShrink: 0,
borderLeft: '1px solid var(--ht-divider)',
background: 'var(--ht-surface)',
display: 'flex',
flexDirection: 'column',
}}
>
<ToolbarBand tone="panel" />
<div style={{ padding: '0 14px 16px', display: 'flex', flexDirection: 'column' }}>
<div style={{ marginTop: -36 }}>
<Skeleton width={72} height={72} radius={999} />
</div>
<div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 6 }}>
<Skeleton width="60%" height={19} />
<Skeleton width="80%" height={11} />
</div>
<div
style={{ height: 1, background: 'var(--ht-divider)', margin: '14px 0' }}
aria-hidden="true"
/>
<Skeleton width="70%" height={11} />
</div>
</aside>
</div>
)
}
118 changes: 103 additions & 15 deletions web/src/components/ConversationScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import {
} from '../lib/actions'
import type { ConversationDetail, ConversationStatus, ThreadView } from '../lib/api-types'
import { clearDraft, getDraft, writeDraft } from '../lib/drafts'
import { messageTime, nameFromEmail, relativeTime, shortDate } from '../lib/format'
import { humanFileSize, messageTime, nameFromEmail, relativeTime, shortDate } from '../lib/format'
import { useStarred } from '../lib/starred'
import { Avatar } from './ds/core/Avatar'
import { Button } from './ds/core/Button'
Expand Down Expand Up @@ -270,6 +270,26 @@ function StarIcon({ filled }: { filled: boolean }) {
)
}

/** HT-46 inbound-attachment marker — the same stroke weight/size family as
* the other inline message-band icons on this screen. */
function PaperclipIcon() {
return (
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M21.44 11.05 12.25 20.24a5.5 5.5 0 0 1-7.78-7.78l9.19-9.19a3.5 3.5 0 0 1 4.95 4.95l-9.2 9.19a1.5 1.5 0 0 1-2.12-2.12l8.49-8.48" />
</svg>
)
}

function PersonIcon() {
return (
<svg
Expand Down Expand Up @@ -656,6 +676,11 @@ export function ConversationScreen({

const [deleteArmed, setDeleteArmed] = useState(false)
const deleteDisarmTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
// Guards changeStatus against concurrent status changes: while one request
// is in flight, a second click is a no-op rather than racing it — so
// `previous` (captured per-call for rollback) is always a value the server
// actually confirmed, never another in-flight call's unconfirmed optimism.
const statusChangeInFlight = useRef(false)

const [openMessageMenuId, setOpenMessageMenuId] = useState<string | null>(null)
const [originalMessage, setOriginalMessage] = useState<ThreadView | null>(null)
Expand Down Expand Up @@ -932,20 +957,35 @@ export function ConversationScreen({
return () => window.removeEventListener('keydown', onKeyDown)
}, [])

function changeStatus(next: ConversationStatus): void {
// Optimistic like tags/assignee below: flip the pill immediately, close the
// menu, and only crawl back to the previous value if the server rejects
// it — never leave the Agent staring at a status that silently reverted
// seconds later with no explanation.
async function changeStatus(next: ConversationStatus): Promise<void> {
if (statusChangeInFlight.current) return
const previous = status
statusChangeInFlight.current = true
setStatusMenuOpen(false)
startTransition(async () => {
setLocalStatus(next)
try {
const result = await setStatusAction(conversation.id, next)
if (result.ok) {
setLocalStatus(next)
showToast({ title: `Marked ${next}` })
router.refresh()
} else {
// Surface the failure like tags/assignee do — never leave a status
// change silently dropped.
if (!result.ok) {
setLocalStatus(previous)
showToast({ title: "Couldn't update the conversation", detail: 'Please try again.' })
return
}
})
showToast({ title: `Marked ${next}` })
} catch {
// The server-action POST itself never completed (offline, unreachable,
// deploy blip) — the client-side promise rejects rather than
// resolving {ok:false}. Treat exactly like a rejected update: roll
// back the optimistic pill and tell the Agent, instead of stranding
// the UI in a status the server never applied.
setLocalStatus(previous)
showToast({ title: "Couldn't update the conversation", detail: 'Please try again.' })
} finally {
statusChangeInFlight.current = false
}
}

async function updateTags(nextTags: string[]): Promise<void> {
Expand Down Expand Up @@ -1223,19 +1263,22 @@ export function ConversationScreen({
onClose={() => setStatusMenuOpen(false)}
align="right"
>
<MenuItem selected={status === 'active'} onClick={() => changeStatus('active')}>
<MenuItem selected={status === 'active'} onClick={() => void changeStatus('active')}>
Active
</MenuItem>
<MenuItem selected={status === 'pending'} onClick={() => changeStatus('pending')}>
<MenuItem
selected={status === 'pending'}
onClick={() => void changeStatus('pending')}
>
Pending
</MenuItem>
<MenuItem selected={status === 'closed'} onClick={() => changeStatus('closed')}>
<MenuItem selected={status === 'closed'} onClick={() => void changeStatus('closed')}>
Closed
</MenuItem>
<MenuItem
selected={status === 'spam'}
destructive
onClick={() => changeStatus('spam')}
onClick={() => void changeStatus('spam')}
>
Spam
</MenuItem>
Expand Down Expand Up @@ -1548,6 +1591,51 @@ export function ConversationScreen({
) : (
(thread.bodyText ?? '')
)}
{/* HT-46 read path, TJ-approved addition beyond the design
prototype (flagged for his sign-off). An empty list
renders nothing — zero layout shift either way. Signed
URLs expire, so this is always the URL exactly as the
API gave it, opened fresh in a new tab rather than
cached or re-derived. */}
{kind === 'inbound' && thread.attachments.length > 0 && (
<div
style={{
marginTop: 8,
display: 'flex',
flexWrap: 'wrap',
gap: 6,
}}
>
{thread.attachments.map((attachment) => (
<a
key={attachment.id}
href={attachment.url}
target="_blank"
rel="noopener noreferrer"
title={`Download ${attachment.filename ?? 'attachment'}`}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
fontSize: 12.5,
color: 'var(--ht-ink-muted)',
textDecoration: 'none',
background: 'var(--ht-surface-2)',
borderRadius: 999,
padding: '4px 10px 4px 8px',
}}
>
<PaperclipIcon />
<span style={{ fontWeight: 600, color: 'var(--ht-ink)' }}>
{attachment.filename ?? 'Attachment'}
</span>
<span style={{ color: 'var(--ht-ink-dim)' }}>
{humanFileSize(attachment.size)}
</span>
</a>
))}
</div>
)}
</MessageBand>
<MessageMenu
open={openMessageMenuId === thread.id}
Expand Down
20 changes: 20 additions & 0 deletions web/src/lib/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@ export interface ConversationSummary {
updatedAt: string
}

/** v1.1 (HT-46) — one inbound attachment's metadata plus a time-limited
* signed `BlobStore` URL (never a stable/public path; it expires). */
export interface AttachmentView {
id: string
/** null when the attachment arrived with no filename. */
filename: string | null
contentType: string
/** bytes */
size: number
url: string
}

export interface ThreadView {
id: string
direction: 'inbound' | 'outbound' | 'note'
Expand All @@ -31,6 +43,14 @@ export interface ThreadView {
bodyHtml: string | null
deliveryStatus: 'pending' | 'sent' | 'failed' | null
customerViewedAt: string | null
/** v1.1 (HT-46) — inbound attachments this thread carries; per spec §2 the
* server ALWAYS emits this field, `[]` when there are none, or when the
* deployment hasn't wired the attachment read-path (config-gated, same
* posture as open tracking) — never absent. Required (not optional), like
* the sibling config-gated field `customerViewedAt` is required-nullable,
* so a server regression that drops the field fails the type at the
* boundary instead of silently rendering as "no attachments". */
attachments: AttachmentView[]
createdAt: string
}

Expand Down
15 changes: 15 additions & 0 deletions web/src/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,18 @@ export function messageTime(iso: string, now: Date = new Date()): string {
}
return shortDate(iso, now)
}

/** "412 B" / "3.4 KB" / "1.2 MB" — one decimal below 10 of a unit, none at
* or above (matches the tabular-numeral brevity the design system's
* other formatters use). Used for HT-46 inbound attachment sizes. */
export function humanFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
const units = ['KB', 'MB', 'GB', 'TB']
let value = bytes / 1024
let unitIndex = 0
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024
unitIndex += 1
}
return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${units[unitIndex]}`
}
Loading