+
@@ -239,6 +292,35 @@ onMounted(loadSources)
letter-spacing: 0.05em;
}
+.chunk-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.chunk-actions {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+}
+
+.link-btn {
+ background: none;
+ border: none;
+ color: var(--text-dim);
+ font-family: var(--font-mono);
+ font-size: 10px;
+ cursor: pointer;
+ text-decoration: underline;
+ padding: 0;
+}
+.link-btn:hover { color: var(--text-primary); }
+
+.selection-count {
+ font-size: 10px;
+ color: var(--text-secondary);
+}
+
.source-list, .chunk-list {
max-height: 200px;
overflow-y: auto;
diff --git a/ui/detection-app/src/stores/pipeline.ts b/ui/detection-app/src/stores/pipeline.ts
index f1e1246..d60672a 100644
--- a/ui/detection-app/src/stores/pipeline.ts
+++ b/ui/detection-app/src/stores/pipeline.ts
@@ -24,6 +24,8 @@ export const usePipelineStore = defineStore('pipeline', () => {
const layoutMode = ref
('normal')
const editorStage = ref(null)
+ const sourceHasSelection = ref(false)
+
const isRunning = computed(() => status.value === 'running')
const isPaused = computed(() => status.value === 'paused')
const canReplay = computed(() => checkpoints.value.length > 0)
@@ -76,12 +78,14 @@ export const usePipelineStore = defineStore('pipeline', () => {
function closeEditor() {
layoutMode.value = 'normal'
editorStage.value = null
+ sourceHasSelection.value = false
}
function reset() {
status.value = 'idle'
layoutMode.value = 'normal'
editorStage.value = null
+ sourceHasSelection.value = false
nodes.value = []
currentStage.value = null
runId.value = null
@@ -92,7 +96,7 @@ export const usePipelineStore = defineStore('pipeline', () => {
return {
jobId, status, nodes, currentStage, runId, parentJobId, runType,
- checkpoints, error, layoutMode, editorStage,
+ checkpoints, error, layoutMode, editorStage, sourceHasSelection,
isRunning, isPaused, canReplay, isEditing,
setJob, setStatus, updateNodes, setRunContext, setCheckpoints, setError,
openSourceSelector, openBBoxEditor, openStageEditor, closeEditor, reset,
diff --git a/ui/framework/src/renderers/GraphRenderer.vue b/ui/framework/src/renderers/GraphRenderer.vue
index 9be629d..dce6ff3 100644
--- a/ui/framework/src/renderers/GraphRenderer.vue
+++ b/ui/framework/src/renderers/GraphRenderer.vue
@@ -6,7 +6,7 @@ import '@vue-flow/core/dist/theme-default.css'
export interface GraphNode {
id: string
- status: 'pending' | 'running' | 'done' | 'error' | 'skipped'
+ status: 'pending' | 'running' | 'done' | 'error' | 'skipped' | 'placeholder'
/** Whether a checkpoint exists at this stage */
hasCheckpoint?: boolean
/** Stage category (e.g. 'cv', 'ai', 'preprocessing') */
@@ -44,6 +44,7 @@ const STATUS_COLORS: Record = {
done: 'var(--status-live)',
error: 'var(--status-error)',
skipped: '#4a6fa5',
+ placeholder: 'transparent',
}
function nodeAppearance(node: GraphNode) {
@@ -84,6 +85,16 @@ function nodeAppearance(node: GraphNode) {
}
}
+ // Placeholder: hollow, no text
+ if (node.status === 'placeholder') {
+ return {
+ color: 'transparent',
+ textColor: 'transparent',
+ opacity: 0.6,
+ outline: false,
+ }
+ }
+
// Default: observe mode or downstream in edit-in-pipeline
return {
color: STATUS_COLORS[node.status] ?? STATUS_COLORS.pending,
@@ -158,6 +169,7 @@ function onNodeClick(id: string) {
active: data.isActive,
outline: data.outline,
dimmed: data.opacity < 1,
+ placeholder: data.status === 'placeholder',
}"
:style="{
background: data.color,
@@ -248,6 +260,18 @@ function onNodeClick(id: string) {
pointer-events: none;
}
+.stage-node.placeholder {
+ border: 1px dashed var(--text-secondary);
+ background: transparent;
+ color: transparent;
+ pointer-events: none;
+}
+
+.stage-node.placeholder .stage-actions,
+.stage-node.placeholder .checkpoint-badge {
+ display: none;
+}
+
.stage-label {
flex: 1;
}