feat(14.1-04): browse rows carry topics+analysis_status+is_stale; StorageBrowser parity slots

- CloudItemOut gains lightweight row fields: topics (List[str]), analysis_status
  (Optional[str]), is_stale (bool) — allowlisted, no extracted_text (T-14.1-10)
- browse.py _item_out populates status/is_stale from CloudItem; batched topic-name
  load via _batch_load_topics (single JOIN query; no N+1) for file rows only
- StorageBrowser imports cloudConnections store; translateStatus delegates to
  store.translateAnalysisStatus (D-06 single-source rule)
- cloudRowActionKind() drives single analysis action slot: Analyze (pending) /
  Re-analyze (indexed/stale) / Retry (failed) — one renders at a time (D-08/D-10)
- Inline analysis_status badge added for cloud file rows (green/amber/blue/red per UI-SPEC)
- TopicBadge rendering handles both string[] (cloud) and object[] (local) topics
- All 489 frontend tests pass; all 52 backend cloud tests pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-06-26 22:21:30 +02:00
co-authored by Claude Sonnet 4.6
parent 14d7a47d4d
commit 935accc91f
3 changed files with 208 additions and 35 deletions
@@ -604,28 +604,81 @@
<p class="text-sm font-medium text-gray-900 truncate">{{ file.original_name ?? file.name }}</p>
<span v-if="file.is_shared" class="shrink-0 bg-indigo-50 text-indigo-600 text-xs font-medium px-2 py-0.5 rounded-full">Shared</span>
<!--
Phase 14 / D-01: Analyze button inside the name cell so it does not
appear in [data-test="file-row-actions"] (which contains capability-gated
buttons). Analyze is always available in cloud mode not capability-gated.
Phase 14.1 / D-06 / D-08 / D-10: Single analysis action slot in the name
cell. Swaps between Analyze / Re-analyze / Retry by analysis_status.
Only one of the three renders at any given time (v-if / v-else-if chain).
Analyze not yet analyzed (pending / no status)
Re-analyze previously indexed or stale; force=true re-queue (D-11)
Retry analysis failed/partial
Unsupported items render nothing here (analysis_status = "unsupported").
This replaces the old single "Analyze" button that showed for all
cloud files regardless of state (Phase 14 D-01 original).
-->
<button
v-if="mode === 'cloud'"
type="button"
data-test="analyze-file"
title="Analyze"
aria-label="Analyze"
class="shrink-0 p-1 rounded transition-colors text-gray-400 hover:text-violet-600 hover:bg-violet-50 active:bg-violet-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 opacity-0 group-hover:opacity-100"
@click.stop="$emit('analyze-file', file)"
<template v-if="mode === 'cloud'">
<!-- Analyze: not yet analyzed -->
<button
v-if="cloudRowActionKind(file) === 'analyze'"
type="button"
data-test="analyze-file"
title="Analyze"
aria-label="Analyze"
class="shrink-0 p-1 rounded transition-colors text-gray-400 hover:text-violet-600 hover:bg-violet-50 active:bg-violet-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 opacity-0 group-hover:opacity-100"
@click.stop="$emit('analyze-file', file)"
>
<AppIcon name="sparkles" class="w-3.5 h-3.5" />
</button>
<!-- Re-analyze: indexed or stale -->
<button
v-else-if="cloudRowActionKind(file) === 'reanalyze'"
type="button"
data-test="reanalyze-file"
title="Re-analyze"
aria-label="Re-analyze"
class="shrink-0 px-1.5 py-0.5 rounded text-xs font-medium transition-colors text-violet-600 hover:text-violet-700 hover:bg-violet-50 active:bg-violet-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 opacity-0 group-hover:opacity-100"
@click.stop="$emit('analyze-file', file)"
>
Re-analyze
</button>
<!-- Retry: failed analysis -->
<button
v-else-if="cloudRowActionKind(file) === 'retry'"
type="button"
data-test="retry-analysis-file"
title="Retry analysis"
aria-label="Retry analysis"
class="shrink-0 px-1.5 py-0.5 rounded text-xs font-medium transition-colors text-amber-600 hover:text-amber-700 hover:bg-amber-50 active:bg-amber-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 opacity-0 group-hover:opacity-100"
@click.stop="$emit('analyze-file', file)"
>
Retry
</button>
</template>
</div>
<!--
Phase 14.1 / D-06: Inline status badge for cloud file rows.
Shows current analysis state in the same relative slot as the local
file row status (name-cell zone). Only rendered for cloud files that
have been through analysis (not pending).
Colors per UI-SPEC: green current, amber stale/skipped, blue working, red failed.
-->
<div v-if="mode === 'cloud' && file.analysis_status && file.analysis_status !== 'pending'"
class="flex items-center gap-1 mt-0.5 flex-wrap"
>
<span
:class="['inline-flex items-center px-1.5 py-0 rounded-full text-xs font-medium', cloudRowStatusBadgeClass(file.analysis_status)]"
:data-test="`analysis-status-${file.analysis_status}`"
>
<AppIcon name="sparkles" class="w-3.5 h-3.5" />
</button>
{{ cloudRowStatusLabel(file.analysis_status) }}
</span>
</div>
<div v-if="file.topics?.length" class="flex items-center gap-1 mt-0.5 flex-wrap">
<TopicBadge
v-for="t in file.topics.slice(0, 3)"
:key="t"
:name="t"
:color="topicColor(t)"
v-for="t in (typeof file.topics[0] === 'string' ? file.topics : file.topics.map(tt => tt.name)).slice(0, 3)"
:key="typeof t === 'string' ? t : t.name"
:name="typeof t === 'string' ? t : t.name"
:color="topicColor(typeof t === 'string' ? t : t.name)"
/>
</div>
</div>
@@ -762,6 +815,12 @@ import DropZone from '../upload/DropZone.vue'
import UploadProgress from '../upload/UploadProgress.vue'
import TopicBadge from '../topics/TopicBadge.vue'
import { formatDate, formatSize } from '../../utils/formatters.js'
import { useCloudConnectionsStore } from '../../stores/cloudConnections.js'
// D-06: Single status translation source — store owns the canonical map.
// StorageBrowser delegates to this; the local translateStatus function below
// also delegates so existing call-sites are preserved.
const cloudStore = useCloudConnectionsStore()
// ── CapabilityButton inline component ──────────────────────────────────────────
/**
@@ -1018,21 +1077,67 @@ const internalSelectedItems = ref(new Set(props.selectedItems ?? []))
/** Expand/collapse state for the analysis item queue list. */
const analysisQueueExpanded = ref(false)
/** D-06: Internal simplified status translation for display. */
/**
* D-06: Status translation delegates to the store's translateAnalysisStatus.
* The store is the single source of truth for internal status → UI label mapping
* (CLAUDE.md: "translateAnalysisStatus in cloudConnections store is the single
* translation source — components never translate internal status strings
* independently").
*
* The function signature is preserved so existing call-sites (statusBadgeClass,
* etc.) continue to work without modification.
*/
function translateStatus(status) {
const SIMPLIFIED = {
queued: 'waiting',
downloading: 'working',
extracting: 'working',
classifying: 'working',
indexed: 'done',
already_current: 'done',
cancelled: 'skipped',
failed: 'failed',
unsupported: 'skipped',
stale: 'working',
}
return SIMPLIFIED[status] ?? status
return cloudStore.translateAnalysisStatus(status)
}
/**
* D-06: Determine the analysis action kind for a cloud file row.
* Returns 'analyze' | 'reanalyze' | 'retry' | null based on analysis_status.
*
* - null/pending/none → 'analyze' (not yet analyzed)
* - indexed/current/stale → 'reanalyze' (analyzed; re-analyze available)
* - failed/partial → 'retry' (failed; retry available)
* - unsupported → null (analysis not supported — no action rendered)
*/
function cloudRowActionKind(file) {
const status = file.analysis_status
if (!status || status === 'pending' || status === 'none') return 'analyze'
if (status === 'indexed' || status === 'already_current' || status === 'stale' || status === 'current') return 'reanalyze'
if (status === 'failed' || status === 'partial') return 'retry'
if (status === 'unsupported') return null
// Default: show analyze for unknown states
return 'analyze'
}
/**
* Phase 14.1 / D-06: CSS class for inline analysis-status badge in cloud file rows.
* Maps simplified status to a color per the UI-SPEC:
* green — current/indexed
* amber — stale/skipped
* blue — working/waiting
* red — failed
*/
function cloudRowStatusBadgeClass(status) {
const simplified = translateStatus(status)
if (simplified === 'done') return 'bg-green-100 text-green-700'
if (simplified === 'skipped') return 'bg-amber-100 text-amber-700'
if (simplified === 'working' || simplified === 'waiting') return 'bg-blue-100 text-blue-700'
if (simplified === 'failed') return 'bg-red-100 text-red-700'
return 'bg-gray-100 text-gray-500'
}
/**
* Phase 14.1 / D-06: Human-readable status label for inline cloud file row badge.
* Stale shows "stale" (not "working") in the row — distinct from queue-item status.
*/
function cloudRowStatusLabel(status) {
if (status === 'stale') return 'stale'
if (status === 'indexed' || status === 'already_current' || status === 'current') return 'indexed'
if (status === 'failed') return 'failed'
if (status === 'unsupported') return 'unsupported'
if (status === 'pending' || !status) return 'pending'
return translateStatus(status)
}
/** Toggle a file in the internal selection set (cloud mode only). */