mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
* feat(hub,shared): scratchlist v2.2 hub attachment storage foundation (#921) Hub stores scratchlist attachment bytes on filesystem; SQLite holds AttachmentMetadata[] JSON via session_scratchlist.attachments (v11→v12). Upstream ladder: v10→v11 text-only scratchlist table (#896), v11→v12 attachments column. Configurable limits via HAPI_SCRATCHLIST_* env vars. Upload, serve, and limits REST routes; delete entry cleans hub files. Web promote/rehydrate still TODO. Soup renumber branch follows. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(web): scratchlist v2.2 attachment UX (#921) Route scratchlist-mode composer submits with attachments to hub storage, show image thumbnails in the drawer, and rehydrate attachments on promote to composer or queue (hub fetch → CLI upload for send). Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): scratchlist attach submit, float thumbs, copy tooltip (#921) Hub upload adapter now sets path on ready attachments so the composer send button unlocks in scratchlist mode; routing label matches attachments too. Entry thumbnails float left with text wrap; copy tooltip clarifies text-only. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hub): adapt scratchlist update tests to patch API (#921) update() now takes { text?, attachments? }; v12 CRUD tests still passed a string. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hub,web): harden scratchlist attachment ownership and orphan cleanup Resolve claimed hub paths against the current session before persist, count on-disk session bytes for upload caps, delete blobs dropped on entry update, and DELETE pending uploads when composer remove runs. Co-authored-by: Cursor <cursoragent@cursor.com> * chore: drop accidental .cursor files from attachment PR Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): exit scratchlist mode before rehydrate; delete raced uploads Promote-to-composer flushes mode exit so attachments use the chat adapter. Cancel-during-upload deletes the hub blob once upload returns. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hub,web): exact UUID delete match; stage hub paths on chat send Reject partial attachment ids on disk delete, and restage scratchlist hub attachments through uploadFile when sending after leaving scratchlist mode. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hub): skip text-only PUT resolve; cleanup session attachment dirs Text-only edits keep existing attachment metadata after session-id transfer. Require full UUID on resolve. Delete scratchlist attachment files when a session is deleted. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hub,web): scratchlist attach route, PUT bytes, orphan deletes Park only hub-resident attachments; subtract removed blobs from the PUT session cap; delete attachment files only when no other entry still references them. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hub): canonicalize scratchlist attachment filenames Resolve stores the on-disk sanitized name (not claimed.filename) and hardens Content-Disposition against CR/LF/quote injection. Co-authored-by: Cursor <cursoragent@cursor.com> * test(hub): cover toxic filename canonicalize on resolve Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hub,web): serialize scratchlist uploads; drop hub blobs after chat stage Per-session upload lock keeps disk byte caps honest under concurrency. After a successful toggle-off chat send, delete the staged hub copies so they no longer count against the session attachment budget. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(shared,web): allow clearing scratchlist attachments; cleanup staged uploads PUT may send attachments:[] without a text change. Staging to chat rolls back partial normal-upload copies on failure. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(hub): re-key scratchlist attachment files on session merge Move hub blobs when scratchlist rows transfer between session ids so quota and path ownership stay correct. Reject PUT that would leave an empty textless entry after clearing attachments. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): reuse restored scratchlist hub attachments without re-upload Composer draft remount was re-uploading blobs that already had a hapi-hub:scratchlist path, orphaning the originals against session quota. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
209 lines
6.6 KiB
TypeScript
209 lines
6.6 KiB
TypeScript
/**
|
|
* Per-session scratchlist storage (issue #11).
|
|
*
|
|
* The scratchlist is the operator's *workbench*: notes / drafts / parking lot
|
|
* entries that are explicitly **not** queued for sending. Compare to the
|
|
* queue (`QueuedMessagesBar`), which is a conveyor belt that auto-fires
|
|
* messages in order. Scratchlist entries are held until the operator
|
|
* promotes them (to the composer or into the queue) or deletes them.
|
|
*
|
|
* Storage is per-session in `localStorage` under
|
|
* `hapi.scratchlist.v1.<sessionId>` so entries survive reloads but stay
|
|
* scoped to a single conversation. Hub-sync is intentionally deferred
|
|
* (v2) to keep this PR small.
|
|
*/
|
|
const STORAGE_KEY_PREFIX = 'hapi.scratchlist.v1.'
|
|
|
|
/** Hard upper bound to keep payloads sane and rule out runaway growth. */
|
|
export const SCRATCHLIST_MAX_ENTRIES = 200
|
|
|
|
/** Per-entry text cap: matches what a long composer paste can produce. */
|
|
export const SCRATCHLIST_MAX_TEXT_LENGTH = 10_000
|
|
|
|
import type { ScratchlistAttachmentMetadata } from '@hapi/protocol'
|
|
|
|
export type ScratchlistEntry = {
|
|
id: string
|
|
text: string
|
|
createdAt: number
|
|
/**
|
|
* Last-saved timestamp surfaced by the entry-age indicator (clock
|
|
* icon + tooltip). Optional so v1-only callers (the standalone
|
|
* panel fixture, legacy localStorage rows that pre-date v2) keep
|
|
* working - readers fall back to `createdAt` when absent.
|
|
*/
|
|
updatedAt?: number
|
|
attachments?: ScratchlistAttachmentMetadata[]
|
|
}
|
|
|
|
function getStorageKey(sessionId: string): string {
|
|
return `${STORAGE_KEY_PREFIX}${sessionId}`
|
|
}
|
|
|
|
function getLocalStorage(): Storage | null {
|
|
if (typeof window === 'undefined') {
|
|
return null
|
|
}
|
|
try {
|
|
return window.localStorage
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function isEntry(value: unknown): value is ScratchlistEntry {
|
|
if (!value || typeof value !== 'object') return false
|
|
const entry = value as Record<string, unknown>
|
|
if (
|
|
typeof entry.id !== 'string'
|
|
|| entry.id.length === 0
|
|
|| typeof entry.text !== 'string'
|
|
|| typeof entry.createdAt !== 'number'
|
|
|| !Number.isFinite(entry.createdAt)
|
|
) return false
|
|
if (entry.updatedAt !== undefined) {
|
|
if (typeof entry.updatedAt !== 'number' || !Number.isFinite(entry.updatedAt)) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
export function readScratchlist(sessionId: string): ScratchlistEntry[] {
|
|
if (!sessionId) return []
|
|
const storage = getLocalStorage()
|
|
if (!storage) return []
|
|
|
|
let raw: string | null
|
|
try {
|
|
raw = storage.getItem(getStorageKey(sessionId))
|
|
} catch {
|
|
return []
|
|
}
|
|
if (!raw) return []
|
|
|
|
let parsed: unknown
|
|
try {
|
|
parsed = JSON.parse(raw)
|
|
} catch {
|
|
return []
|
|
}
|
|
if (!Array.isArray(parsed)) return []
|
|
|
|
const entries: ScratchlistEntry[] = []
|
|
for (const item of parsed) {
|
|
if (isEntry(item)) entries.push(item)
|
|
if (entries.length >= SCRATCHLIST_MAX_ENTRIES) break
|
|
}
|
|
return entries
|
|
}
|
|
|
|
function writeScratchlist(sessionId: string, entries: ScratchlistEntry[]): void {
|
|
if (!sessionId) return
|
|
const storage = getLocalStorage()
|
|
if (!storage) return
|
|
try {
|
|
const trimmed = entries.slice(0, SCRATCHLIST_MAX_ENTRIES)
|
|
storage.setItem(getStorageKey(sessionId), JSON.stringify(trimmed))
|
|
} catch {
|
|
// Storage quota or serialization failures are non-fatal: the in-memory
|
|
// copy still works for the rest of the session.
|
|
}
|
|
}
|
|
|
|
function makeEntryId(): string {
|
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
return crypto.randomUUID()
|
|
}
|
|
return `scratch-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
|
}
|
|
|
|
/**
|
|
* Append a new entry to the scratchlist. Returns the new entry list (or
|
|
* the previous list unchanged when text is empty / would exceed the cap).
|
|
*
|
|
* Trimming behavior: leading/trailing whitespace stripped; empty input
|
|
* is rejected (returns the input list unchanged). Entries longer than
|
|
* `SCRATCHLIST_MAX_TEXT_LENGTH` are truncated rather than rejected so
|
|
* pasting a giant blob still ends up captured.
|
|
*/
|
|
export function addScratchlistEntry(
|
|
entries: ScratchlistEntry[],
|
|
rawText: string,
|
|
now: number = Date.now()
|
|
): { entries: ScratchlistEntry[]; added: ScratchlistEntry | null } {
|
|
const text = rawText.trim()
|
|
if (text.length === 0) {
|
|
return { entries, added: null }
|
|
}
|
|
const truncated = text.length > SCRATCHLIST_MAX_TEXT_LENGTH
|
|
? text.slice(0, SCRATCHLIST_MAX_TEXT_LENGTH)
|
|
: text
|
|
const entry: ScratchlistEntry = {
|
|
id: makeEntryId(),
|
|
text: truncated,
|
|
createdAt: now,
|
|
}
|
|
// Newest-first ordering: matches the way operators read the workbench
|
|
// (most recent thought at the top, scrolling down for older).
|
|
const next = [entry, ...entries].slice(0, SCRATCHLIST_MAX_ENTRIES)
|
|
return { entries: next, added: entry }
|
|
}
|
|
|
|
export function deleteScratchlistEntry(
|
|
entries: ScratchlistEntry[],
|
|
id: string
|
|
): ScratchlistEntry[] {
|
|
return entries.filter((e) => e.id !== id)
|
|
}
|
|
|
|
/**
|
|
* Move an entry up (toward index 0) or down (toward the end). Out-of-range
|
|
* moves are no-ops so the UI can call this unconditionally without first
|
|
* checking position.
|
|
*/
|
|
export function moveScratchlistEntry(
|
|
entries: ScratchlistEntry[],
|
|
id: string,
|
|
direction: 'up' | 'down'
|
|
): ScratchlistEntry[] {
|
|
const index = entries.findIndex((e) => e.id === id)
|
|
if (index < 0) return entries
|
|
const swapWith = direction === 'up' ? index - 1 : index + 1
|
|
if (swapWith < 0 || swapWith >= entries.length) return entries
|
|
const next = [...entries]
|
|
const tmp = next[index]
|
|
const other = next[swapWith]
|
|
if (!tmp || !other) return entries
|
|
next[index] = other
|
|
next[swapWith] = tmp
|
|
return next
|
|
}
|
|
|
|
export function persistScratchlist(sessionId: string, entries: ScratchlistEntry[]): void {
|
|
writeScratchlist(sessionId, entries)
|
|
}
|
|
|
|
export function clearScratchlist(sessionId: string): void {
|
|
if (!sessionId) return
|
|
const storage = getLocalStorage()
|
|
if (!storage) return
|
|
try {
|
|
storage.removeItem(getStorageKey(sessionId))
|
|
} catch {
|
|
// Non-fatal.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Confirm-on-delete threshold. Trivial entries delete instantly; longer
|
|
* notes deserve a confirmation prompt so a stray click doesn't lose work.
|
|
* Threshold tuned to "anything longer than a one-line reminder".
|
|
*/
|
|
export const SCRATCHLIST_CONFIRM_DELETE_THRESHOLD = 100
|
|
|
|
export function shouldConfirmDelete(entry: ScratchlistEntry | null | undefined): boolean {
|
|
if (!entry) return false
|
|
return entry.text.length > SCRATCHLIST_CONFIRM_DELETE_THRESHOLD
|
|
}
|