feat(#216): E4/E5 — Doc workspace shell, DocTreeRail, DocWorkspaceHeader, editable chunks editor

Closes #216
Closes #217
Closes #218
Closes #219
Closes #220
Closes #221
Closes #222
This commit is contained in:
Pier-Jean Malandrino 2026-04-30 09:48:41 +02:00
parent 59af8acdc6
commit ceaa039bc1
18 changed files with 2450 additions and 22 deletions

View file

@ -0,0 +1,131 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
fetchChunks,
updateChunk,
mergeChunks,
splitChunk,
dropChunk,
addChunk,
fetchChunkDiff,
pushChunksToStore,
} from './api'
vi.mock('../../shared/api/http', () => ({
apiFetch: vi.fn(),
}))
import { apiFetch } from '../../shared/api/http'
describe('chunks API', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('fetchChunks calls GET /api/documents/:id/chunks', async () => {
const chunks = [{ id: 'c1', docId: 'd1', text: 'hello' }]
apiFetch.mockResolvedValue(chunks)
const result = await fetchChunks('d1')
expect(apiFetch).toHaveBeenCalledWith('/api/documents/d1/chunks')
expect(result).toEqual(chunks)
})
it('updateChunk calls PATCH with patch body', async () => {
const chunk = { id: 'c1', docId: 'd1', text: 'updated' }
apiFetch.mockResolvedValue(chunk)
const result = await updateChunk('d1', 'c1', { text: 'updated' })
expect(apiFetch).toHaveBeenCalledWith('/api/documents/d1/chunks/c1', {
method: 'PATCH',
body: JSON.stringify({ text: 'updated' }),
})
expect(result).toEqual(chunk)
})
it('mergeChunks calls POST /chunks/merge with ids', async () => {
const merged = [{ id: 'cm', docId: 'd1', text: 'merged' }]
apiFetch.mockResolvedValue(merged)
const result = await mergeChunks('d1', ['c1', 'c2'])
expect(apiFetch).toHaveBeenCalledWith('/api/documents/d1/chunks/merge', {
method: 'POST',
body: JSON.stringify({ ids: ['c1', 'c2'] }),
})
expect(result).toEqual(merged)
})
it('splitChunk calls POST /chunks/:id/split with cursorOffset', async () => {
const split = [
{ id: 'c1a', docId: 'd1', text: 'part1' },
{ id: 'c1b', docId: 'd1', text: 'part2' },
]
apiFetch.mockResolvedValue(split)
const result = await splitChunk('d1', 'c1', 10)
expect(apiFetch).toHaveBeenCalledWith('/api/documents/d1/chunks/c1/split', {
method: 'POST',
body: JSON.stringify({ cursorOffset: 10 }),
})
expect(result).toEqual(split)
})
it('dropChunk calls DELETE /api/documents/:id/chunks/:chunkId', async () => {
apiFetch.mockResolvedValue(undefined)
await dropChunk('d1', 'c1')
expect(apiFetch).toHaveBeenCalledWith('/api/documents/d1/chunks/c1', { method: 'DELETE' })
})
it('addChunk calls POST /chunks with text and afterId', async () => {
const newChunk = { id: 'cnew', docId: 'd1', text: 'new' }
apiFetch.mockResolvedValue(newChunk)
const result = await addChunk('d1', 'new', 'c1')
expect(apiFetch).toHaveBeenCalledWith('/api/documents/d1/chunks', {
method: 'POST',
body: JSON.stringify({ text: 'new', afterId: 'c1' }),
})
expect(result).toEqual(newChunk)
})
it('addChunk works without afterId', async () => {
const newChunk = { id: 'cnew', docId: 'd1', text: 'new' }
apiFetch.mockResolvedValue(newChunk)
await addChunk('d1', 'new')
expect(apiFetch).toHaveBeenCalledWith('/api/documents/d1/chunks', {
method: 'POST',
body: JSON.stringify({ text: 'new', afterId: undefined }),
})
})
it('fetchChunkDiff calls GET /diff with encoded store param', async () => {
const diffs = [{ chunkId: 'c1', status: 'modified', textDiff: 'diff' }]
apiFetch.mockResolvedValue(diffs)
const result = await fetchChunkDiff('d1', 'my store')
expect(apiFetch).toHaveBeenCalledWith('/api/documents/d1/diff?store=my%20store')
expect(result).toEqual(diffs)
})
it('pushChunksToStore calls POST /chunks/push with store', async () => {
const response = { jobId: 'j1', summary: { embeds: 5, tokens: 1000 } }
apiFetch.mockResolvedValue(response)
const result = await pushChunksToStore('d1', 'my-store')
expect(apiFetch).toHaveBeenCalledWith('/api/documents/d1/chunks/push', {
method: 'POST',
body: JSON.stringify({ store: 'my-store' }),
})
expect(result).toEqual(response)
})
})

View file

@ -0,0 +1,60 @@
import type { DocChunk, ChunkDiff, PushSummary } from '../../shared/types'
import { apiFetch } from '../../shared/api/http'
export function fetchChunks(docId: string): Promise<DocChunk[]> {
return apiFetch<DocChunk[]>(`/api/documents/${docId}/chunks`)
}
export function updateChunk(
docId: string,
chunkId: string,
patch: { text?: string; title?: string },
): Promise<DocChunk> {
return apiFetch<DocChunk>(`/api/documents/${docId}/chunks/${chunkId}`, {
method: 'PATCH',
body: JSON.stringify(patch),
})
}
export function mergeChunks(docId: string, ids: string[]): Promise<DocChunk[]> {
return apiFetch<DocChunk[]>(`/api/documents/${docId}/chunks/merge`, {
method: 'POST',
body: JSON.stringify({ ids }),
})
}
export function splitChunk(
docId: string,
chunkId: string,
cursorOffset: number,
): Promise<DocChunk[]> {
return apiFetch<DocChunk[]>(`/api/documents/${docId}/chunks/${chunkId}/split`, {
method: 'POST',
body: JSON.stringify({ cursorOffset }),
})
}
export function dropChunk(docId: string, chunkId: string): Promise<void> {
return apiFetch(`/api/documents/${docId}/chunks/${chunkId}`, { method: 'DELETE' })
}
export function addChunk(docId: string, text: string, afterId?: string): Promise<DocChunk> {
return apiFetch<DocChunk>(`/api/documents/${docId}/chunks`, {
method: 'POST',
body: JSON.stringify({ text, afterId }),
})
}
export function fetchChunkDiff(docId: string, store: string): Promise<ChunkDiff[]> {
return apiFetch<ChunkDiff[]>(`/api/documents/${docId}/diff?store=${encodeURIComponent(store)}`)
}
export function pushChunksToStore(
docId: string,
store: string,
): Promise<{ jobId: string; summary: PushSummary }> {
return apiFetch<{ jobId: string; summary: PushSummary }>(`/api/documents/${docId}/chunks/push`, {
method: 'POST',
body: JSON.stringify({ store }),
})
}

View file

@ -0,0 +1,2 @@
export { useChunksStore } from './store'
export * from './api'

View file

@ -0,0 +1,137 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useChunksStore } from './store'
vi.mock('./api', () => ({
fetchChunks: vi.fn(),
updateChunk: vi.fn(),
mergeChunks: vi.fn(),
splitChunk: vi.fn(),
dropChunk: vi.fn(),
addChunk: vi.fn(),
fetchChunkDiff: vi.fn(),
pushChunksToStore: vi.fn(),
}))
import * as api from './api'
const makeChunk = (id: string, text = 'text') => ({
id,
docId: 'd1',
text,
createdAt: '2025-01-01T00:00:00Z',
updatedAt: '2025-01-01T00:00:00Z',
})
describe('useChunksStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('load — sets chunks and clears loading', async () => {
const chunks = [makeChunk('c1'), makeChunk('c2')]
api.fetchChunks.mockResolvedValue(chunks)
const store = useChunksStore()
const loadPromise = store.load('d1')
expect(store.loading).toBe(true)
await loadPromise
expect(store.loading).toBe(false)
expect(store.chunks).toEqual(chunks)
})
it('load — sets error on failure', async () => {
api.fetchChunks.mockRejectedValue(new Error('boom'))
const store = useChunksStore()
await store.load('d1')
expect(store.error).toBe('boom')
expect(store.chunks).toEqual([])
})
it('updateText — replaces chunk in list', async () => {
const store = useChunksStore()
store.chunks = [makeChunk('c1', 'original')]
const updated = makeChunk('c1', 'changed')
api.updateChunk.mockResolvedValue(updated)
await store.updateText('d1', 'c1', 'changed')
expect(store.chunks[0].text).toBe('changed')
})
it('updateTitle — replaces chunk in list', async () => {
const store = useChunksStore()
store.chunks = [makeChunk('c1')]
const updated = { ...makeChunk('c1'), title: 'New title' }
api.updateChunk.mockResolvedValue(updated)
await store.updateTitle('d1', 'c1', 'New title')
expect(store.chunks[0].title).toBe('New title')
})
it('drop — removes chunk from list', async () => {
const store = useChunksStore()
store.chunks = [makeChunk('c1'), makeChunk('c2')]
api.dropChunk.mockResolvedValue(undefined)
await store.drop('d1', 'c1')
expect(store.chunks).toHaveLength(1)
expect(store.chunks[0].id).toBe('c2')
})
it('add — appends at end when no afterId', async () => {
const store = useChunksStore()
store.chunks = [makeChunk('c1')]
const created = makeChunk('cnew', 'new text')
api.addChunk.mockResolvedValue(created)
await store.add('d1', 'new text')
expect(store.chunks).toHaveLength(2)
expect(store.chunks[1].id).toBe('cnew')
})
it('add — inserts after given chunk', async () => {
const store = useChunksStore()
store.chunks = [makeChunk('c1'), makeChunk('c2')]
const created = makeChunk('cnew', 'new text')
api.addChunk.mockResolvedValue(created)
await store.add('d1', 'new text', 'c1')
expect(store.chunks[1].id).toBe('cnew')
expect(store.chunks[2].id).toBe('c2')
})
it('loadDiff — sets diff', async () => {
const diffs = [{ chunkId: 'c1', status: 'modified' as const, textDiff: 'x' }]
api.fetchChunkDiff.mockResolvedValue(diffs)
const store = useChunksStore()
await store.loadDiff('d1', 'my-store')
expect(store.diff).toEqual(diffs)
})
it('push — returns jobId on success', async () => {
api.pushChunksToStore.mockResolvedValue({ jobId: 'j1', summary: { embeds: 3, tokens: 500 } })
const store = useChunksStore()
const jobId = await store.push('d1', 'my-store')
expect(jobId).toBe('j1')
})
it('push — returns null on failure', async () => {
api.pushChunksToStore.mockRejectedValue(new Error('network'))
const store = useChunksStore()
const jobId = await store.push('d1', 'my-store')
expect(jobId).toBeNull()
expect(store.error).toBe('network')
})
})

View file

@ -0,0 +1,175 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { DocChunk, ChunkDiff } from '../../shared/types'
import * as api from './api'
export const useChunksStore = defineStore('chunks', () => {
const chunks = ref<DocChunk[]>([])
const loading = ref(false)
const saving = ref(false)
const diffing = ref(false)
const diff = ref<ChunkDiff[]>([])
const error = ref<string | null>(null)
function clearError(): void {
error.value = null
}
async function load(docId: string): Promise<void> {
loading.value = true
error.value = null
try {
chunks.value = await api.fetchChunks(docId)
} catch (e) {
error.value = (e as Error).message || 'Failed to load chunks'
console.error('Failed to load chunks', e)
} finally {
loading.value = false
}
}
async function updateText(docId: string, chunkId: string, text: string): Promise<void> {
saving.value = true
try {
const updated = await api.updateChunk(docId, chunkId, { text })
const idx = chunks.value.findIndex((c) => c.id === chunkId)
if (idx !== -1) chunks.value = chunks.value.with(idx, updated)
} catch (e) {
error.value = (e as Error).message || 'Failed to save chunk'
console.error('Failed to save chunk', e)
} finally {
saving.value = false
}
}
async function updateTitle(docId: string, chunkId: string, title: string): Promise<void> {
saving.value = true
try {
const updated = await api.updateChunk(docId, chunkId, { title })
const idx = chunks.value.findIndex((c) => c.id === chunkId)
if (idx !== -1) chunks.value = chunks.value.with(idx, updated)
} catch (e) {
error.value = (e as Error).message || 'Failed to retitle chunk'
console.error('Failed to retitle chunk', e)
} finally {
saving.value = false
}
}
async function merge(docId: string, ids: string[]): Promise<void> {
saving.value = true
try {
const updated = await api.mergeChunks(docId, ids)
const idSet = new Set(ids)
const kept = chunks.value.filter((c) => !idSet.has(c.id))
const firstIdx = chunks.value.findIndex((c) => idSet.has(c.id))
chunks.value = [...kept.slice(0, firstIdx), ...updated, ...kept.slice(firstIdx)]
} catch (e) {
error.value = (e as Error).message || 'Failed to merge chunks'
console.error('Failed to merge chunks', e)
} finally {
saving.value = false
}
}
async function split(docId: string, chunkId: string, cursorOffset: number): Promise<void> {
saving.value = true
try {
const produced = await api.splitChunk(docId, chunkId, cursorOffset)
const idx = chunks.value.findIndex((c) => c.id === chunkId)
if (idx !== -1) {
chunks.value = [...chunks.value.slice(0, idx), ...produced, ...chunks.value.slice(idx + 1)]
}
} catch (e) {
error.value = (e as Error).message || 'Failed to split chunk'
console.error('Failed to split chunk', e)
} finally {
saving.value = false
}
}
async function drop(docId: string, chunkId: string): Promise<void> {
saving.value = true
try {
await api.dropChunk(docId, chunkId)
chunks.value = chunks.value.filter((c) => c.id !== chunkId)
} catch (e) {
error.value = (e as Error).message || 'Failed to drop chunk'
console.error('Failed to drop chunk', e)
} finally {
saving.value = false
}
}
async function add(docId: string, text: string, afterId?: string): Promise<void> {
saving.value = true
try {
const created = await api.addChunk(docId, text, afterId)
if (afterId) {
const idx = chunks.value.findIndex((c) => c.id === afterId)
if (idx !== -1) {
chunks.value = [
...chunks.value.slice(0, idx + 1),
created,
...chunks.value.slice(idx + 1),
]
return
}
}
chunks.value = [...chunks.value, created]
} catch (e) {
error.value = (e as Error).message || 'Failed to add chunk'
console.error('Failed to add chunk', e)
} finally {
saving.value = false
}
}
async function loadDiff(docId: string, store: string): Promise<void> {
diffing.value = true
error.value = null
try {
diff.value = await api.fetchChunkDiff(docId, store)
} catch (e) {
error.value = (e as Error).message || 'Failed to load diff'
console.error('Failed to load diff', e)
} finally {
diffing.value = false
}
}
function clearDiff(): void {
diff.value = []
}
async function push(docId: string, store: string): Promise<string | null> {
try {
const res = await api.pushChunksToStore(docId, store)
return res.jobId
} catch (e) {
error.value = (e as Error).message || 'Failed to push chunks'
console.error('Failed to push chunks', e)
return null
}
}
return {
chunks,
loading,
saving,
diffing,
diff,
error,
clearError,
load,
updateText,
updateTitle,
merge,
split,
drop,
add,
loadDiff,
clearDiff,
push,
}
})

View file

@ -0,0 +1,382 @@
<template>
<div
class="chunk-item"
:class="[
`chunk-item--${diffStatus}`,
{ 'chunk-item--selected': selected, 'chunk-item--editing': editing },
]"
data-e2e="chunk-item"
@click="$emit('selectChunk', chunk.id)"
>
<!-- Selection checkbox -->
<input
type="checkbox"
class="chunk-select"
:checked="selected"
@change.stop="$emit('toggleSelect', chunk.id)"
@click.stop
/>
<!-- Body -->
<div class="chunk-body">
<!-- Title row -->
<div class="chunk-title-row">
<span
v-if="!editingTitle"
class="chunk-title"
:class="{ 'chunk-title--empty': !chunk.title }"
@dblclick.stop="startEditTitle"
>
{{ chunk.title || t('chunks.noTitle') }}
</span>
<input
v-else
ref="titleInput"
v-model="draftTitle"
class="chunk-title-input"
@blur="commitTitle"
@keydown.enter.prevent="commitTitle"
@keydown.escape.prevent="cancelTitle"
@click.stop
/>
<span class="chunk-meta">
<span v-if="chunk.pageRange">p.{{ chunk.pageRange[0] }}{{ chunk.pageRange[1] }}</span>
<span v-if="chunk.tokenCount">{{ chunk.tokenCount }}t</span>
<span v-if="savingThis" class="saving-indicator">{{ t('chunks.saving') }}</span>
</span>
</div>
<!-- Text -->
<textarea
v-if="editing"
ref="textArea"
v-model="draftText"
class="chunk-text-edit"
:placeholder="t('chunks.editPlaceholder')"
rows="4"
@blur="commitText"
@click.stop
@keydown.escape.prevent="cancelText"
/>
<p
v-else
class="chunk-text"
:class="{ 'chunk-text--removed': diffStatus === 'removed' }"
@dblclick.stop="startEdit"
>
{{ chunk.text }}
</p>
<!-- Diff inline text diff -->
<div v-if="diffStatus === 'modified' && chunk.id && diffEntry?.textDiff" class="chunk-diff">
<code class="diff-text">{{ diffEntry.textDiff }}</code>
</div>
</div>
<!-- Actions menu -->
<div class="chunk-actions" @click.stop>
<button
class="chunk-action-btn"
:title="t('chunks.mergePrev')"
:disabled="isFirst"
@click="$emit('mergePrev', chunk.id)"
>
</button>
<button
class="chunk-action-btn"
:title="t('chunks.mergeNext')"
:disabled="isLast"
@click="$emit('mergeNext', chunk.id)"
>
</button>
<button class="chunk-action-btn" :title="t('chunks.split')" @click="splitAtCurrentCursor">
</button>
<button class="chunk-action-btn" :title="t('chunks.add')" @click="$emit('add', chunk.id)">
+
</button>
<button
class="chunk-action-btn chunk-action-btn--danger"
:title="t('chunks.drop')"
@click="$emit('drop', chunk.id)"
>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import type { DocChunk, ChunkDiff, ChunkDiffStatus } from '../../../shared/types'
import { useI18n } from '../../../shared/i18n'
const props = defineProps<{
chunk: DocChunk
selected?: boolean
savingThis?: boolean
isFirst?: boolean
isLast?: boolean
diffEntry?: ChunkDiff | null
}>()
const emit = defineEmits<{
selectChunk: [id: string]
toggleSelect: [id: string]
textChange: [id: string, text: string]
titleChange: [id: string, title: string]
mergePrev: [id: string]
mergeNext: [id: string]
split: [id: string, cursorOffset: number]
drop: [id: string]
add: [afterId: string]
}>()
const { t } = useI18n()
const editing = ref(false)
const editingTitle = ref(false)
const draftText = ref('')
const draftTitle = ref('')
const textArea = ref<HTMLTextAreaElement | null>(null)
const titleInput = ref<HTMLInputElement | null>(null)
const diffStatus = computed<ChunkDiffStatus>(() => props.diffEntry?.status ?? 'unchanged')
function startEdit(): void {
draftText.value = props.chunk.text
editing.value = true
nextTick(() => textArea.value?.focus())
}
function commitText(): void {
if (draftText.value !== props.chunk.text) {
emit('textChange', props.chunk.id, draftText.value)
}
editing.value = false
}
function cancelText(): void {
editing.value = false
}
function startEditTitle(): void {
draftTitle.value = props.chunk.title ?? ''
editingTitle.value = true
nextTick(() => titleInput.value?.focus())
}
function commitTitle(): void {
if (draftTitle.value !== (props.chunk.title ?? '')) {
emit('titleChange', props.chunk.id, draftTitle.value)
}
editingTitle.value = false
}
function cancelTitle(): void {
editingTitle.value = false
}
function splitAtCurrentCursor(): void {
if (!textArea.value) {
emit('split', props.chunk.id, Math.floor(props.chunk.text.length / 2))
return
}
emit('split', props.chunk.id, textArea.value.selectionStart)
}
</script>
<style scoped>
.chunk-item {
display: flex;
gap: 10px;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-elevated);
transition:
border-color var(--transition),
background var(--transition);
cursor: default;
}
.chunk-item:hover {
border-color: var(--border-hover, var(--border));
}
.chunk-item--selected {
border-color: var(--accent);
background: var(--accent-muted);
}
.chunk-item--added {
border-color: var(--success);
background: color-mix(in srgb, var(--success) 8%, transparent);
}
.chunk-item--modified {
border-color: var(--warning);
background: color-mix(in srgb, var(--warning) 8%, transparent);
}
.chunk-item--removed {
border-color: var(--error);
background: color-mix(in srgb, var(--error) 8%, transparent);
opacity: 0.7;
}
.chunk-select {
margin-top: 2px;
flex-shrink: 0;
cursor: pointer;
accent-color: var(--accent);
}
.chunk-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.chunk-title-row {
display: flex;
align-items: center;
gap: 8px;
}
.chunk-title {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
cursor: text;
}
.chunk-title--empty {
color: var(--text-muted);
font-style: italic;
font-weight: 400;
}
.chunk-title-input {
font-size: 12px;
font-weight: 600;
background: var(--bg-surface);
border: 1px solid var(--accent);
border-radius: var(--radius-sm);
padding: 1px 6px;
color: var(--text);
flex: 1;
outline: none;
}
.chunk-meta {
display: flex;
gap: 6px;
font-size: 11px;
font-family: 'IBM Plex Mono', monospace;
color: var(--text-muted);
flex-shrink: 0;
}
.saving-indicator {
color: var(--accent);
animation: blink 1s step-end infinite;
}
@keyframes blink {
50% {
opacity: 0.4;
}
}
.chunk-text {
font-size: 13px;
color: var(--text);
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
cursor: text;
margin: 0;
}
.chunk-text--removed {
text-decoration: line-through;
color: var(--error);
}
.chunk-text-edit {
width: 100%;
font-size: 13px;
font-family: inherit;
color: var(--text);
background: var(--bg-surface);
border: 1px solid var(--accent);
border-radius: var(--radius-sm);
padding: 6px 8px;
resize: vertical;
outline: none;
line-height: 1.6;
}
.chunk-diff {
padding: 4px 8px;
background: var(--bg-surface);
border-radius: var(--radius-sm);
border-left: 3px solid var(--warning);
}
.diff-text {
font-size: 11px;
font-family: 'IBM Plex Mono', monospace;
color: var(--text-secondary);
white-space: pre-wrap;
}
.chunk-actions {
display: flex;
flex-direction: column;
gap: 2px;
flex-shrink: 0;
opacity: 0;
transition: opacity var(--transition);
}
.chunk-item:hover .chunk-actions,
.chunk-item--selected .chunk-actions {
opacity: 1;
}
.chunk-action-btn {
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--text-muted);
transition: all var(--transition);
}
.chunk-action-btn:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.chunk-action-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.chunk-action-btn--danger:hover:not(:disabled) {
border-color: var(--error);
color: var(--error);
}
</style>

View file

@ -0,0 +1,618 @@
<template>
<div class="chunks-editor" data-e2e="chunks-editor">
<!-- Toolbar -->
<div class="chunks-toolbar">
<div class="toolbar-left">
<span class="chunk-count">{{ t('chunks.count', { n: chunksStore.chunks.length }) }}</span>
<span v-if="chunksStore.saving" class="toolbar-saving">{{ t('chunks.saving') }}</span>
</div>
<div class="toolbar-right">
<!-- Diff toggle (#221) -->
<button
class="toolbar-btn"
:class="{ active: showDiff }"
:disabled="!availableStores.length"
@click="toggleDiff"
>
{{ showDiff ? t('chunks.diffClose') : t('chunks.diffToggle') }}
</button>
<!-- Push to store (#222) -->
<button class="toolbar-btn toolbar-btn--primary" @click="openPushModal">
{{ t('chunks.pushTitle') }}
</button>
</div>
</div>
<!-- Diff store selector -->
<div v-if="showDiff" class="diff-bar">
<label class="diff-label">{{ t('chunks.diffRef') }}</label>
<select v-model="diffStore" class="diff-store-select" @change="reloadDiff">
<option v-for="s in availableStores" :key="s" :value="s">{{ s }}</option>
</select>
<span v-if="chunksStore.diffing" class="diff-loading"></span>
<span v-else class="diff-counts">
<span class="diff-added">+{{ diffSummary.added }}</span>
<span class="diff-modified">~{{ diffSummary.modified }}</span>
<span class="diff-removed">-{{ diffSummary.removed }}</span>
</span>
</div>
<!-- Bulk selection bar -->
<div v-if="selectedIds.size > 0" class="bulk-bar" data-e2e="bulk-bar">
<span class="bulk-count">{{ t('chunks.selected', { n: selectedIds.size }) }}</span>
<button class="bulk-btn" @click="bulkDrop">{{ t('chunks.bulkDrop') }}</button>
<button class="bulk-btn" :disabled="selectedIds.size < 2" @click="bulkMerge">
{{ t('chunks.bulkMerge') }}
</button>
<button class="bulk-btn bulk-btn--cancel" @click="selectedIds = new Set()">
{{ t('chunks.bulkCancel') }}
</button>
</div>
<!-- Empty state -->
<div
v-if="!chunksStore.loading && !chunksStore.chunks.length"
class="chunks-empty"
data-e2e="chunks-empty"
>
<p>{{ t('chunks.empty') }}</p>
</div>
<!-- Loading -->
<div v-else-if="chunksStore.loading" class="chunks-loading">
<span class="spinner" />
</div>
<!-- Chunk list -->
<div v-else class="chunk-list" data-e2e="chunk-list">
<ChunkItem
v-for="(chunk, idx) in chunksStore.chunks"
:key="chunk.id"
:chunk="chunk"
:selected="selectedIds.has(chunk.id)"
:saving-this="savingId === chunk.id"
:is-first="idx === 0"
:is-last="idx === chunksStore.chunks.length - 1"
:diff-entry="diffMap.get(chunk.id) ?? null"
@select-chunk="onSelectChunk"
@toggle-select="onToggleSelect"
@text-change="onTextChange"
@title-change="onTitleChange"
@merge-prev="(id) => onMerge(id, 'prev')"
@merge-next="(id) => onMerge(id, 'next')"
@split="onSplit"
@drop="onDrop"
@add="onAdd"
/>
<!-- Add chunk at end -->
<button class="add-chunk-end" @click="onAdd(undefined)">+ {{ t('chunks.add') }}</button>
</div>
<!-- Push to store modal (#222) -->
<div v-if="pushModalOpen" class="modal-backdrop" @click.self="closePushModal">
<div class="modal" role="dialog" :aria-label="t('chunks.pushTitle')" aria-modal="true">
<h2 class="modal-title">{{ t('chunks.pushTitle') }}</h2>
<label class="modal-label">{{ t('chunks.pushStore') }}</label>
<input
ref="pushInput"
v-model="pushStoreName"
class="modal-input"
list="push-store-datalist"
:placeholder="t('chunks.pushPlaceholder')"
@keydown.enter.prevent="confirmPush"
@keydown.escape.prevent="closePushModal"
/>
<datalist id="push-store-datalist">
<option v-for="s in availableStores" :key="s" :value="s" />
</datalist>
<div v-if="pushSummary" class="push-summary">
{{ t('chunks.pushSummary', { embeds: pushSummary.embeds, tokens: pushSummary.tokens }) }}
</div>
<div class="modal-actions">
<button class="modal-btn modal-btn--cancel" @click="closePushModal">
{{ t('chunks.pushCancel') }}
</button>
<button
class="modal-btn modal-btn--primary"
:disabled="!pushStoreName.trim() || pushing"
@click="confirmPush"
>
{{ pushing ? '…' : t('chunks.pushConfirm') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, onMounted } from 'vue'
import { useChunksStore } from '../store'
import { useI18n } from '../../../shared/i18n'
import type { ChunkDiff } from '../../../shared/types'
import ChunkItem from './ChunkItem.vue'
const props = defineProps<{
docId: string
availableStores: string[]
}>()
const emit = defineEmits<{
nodeHighlight: [ref: string | null]
}>()
const { t } = useI18n()
const chunksStore = useChunksStore()
const selectedIds = ref<Set<string>>(new Set())
const savingId = ref<string | null>(null)
// --- Diff state (#221) ---
const showDiff = ref(false)
const diffStore = ref(props.availableStores[0] ?? '')
const diffMap = computed<Map<string, ChunkDiff>>(() => {
const m = new Map<string, ChunkDiff>()
if (!showDiff.value) return m
for (const d of chunksStore.diff) m.set(d.chunkId, d)
return m
})
const diffSummary = computed(() => {
const counts = { added: 0, modified: 0, removed: 0 }
for (const d of chunksStore.diff) {
if (d.status === 'added') counts.added++
else if (d.status === 'modified') counts.modified++
else if (d.status === 'removed') counts.removed++
}
return counts
})
async function toggleDiff(): Promise<void> {
if (showDiff.value) {
showDiff.value = false
chunksStore.clearDiff()
return
}
if (!diffStore.value) diffStore.value = props.availableStores[0] ?? ''
showDiff.value = true
await chunksStore.loadDiff(props.docId, diffStore.value)
}
async function reloadDiff(): Promise<void> {
if (showDiff.value && diffStore.value) {
await chunksStore.loadDiff(props.docId, diffStore.value)
}
}
// --- Push to store (#222) ---
const pushModalOpen = ref(false)
const pushStoreName = ref('')
const pushing = ref(false)
const pushSummary = ref<{ embeds: number; tokens: number } | null>(null)
const pushInput = ref<HTMLInputElement | null>(null)
function openPushModal(): void {
pushStoreName.value = props.availableStores[0] ?? ''
pushSummary.value = null
pushModalOpen.value = true
nextTick(() => pushInput.value?.focus())
}
function closePushModal(): void {
pushModalOpen.value = false
pushing.value = false
}
async function confirmPush(): Promise<void> {
const store = pushStoreName.value.trim()
if (!store || pushing.value) return
pushing.value = true
const jobId = await chunksStore.push(props.docId, store)
pushing.value = false
if (jobId) {
closePushModal()
alert(t('chunks.pushedJob', { jobId }))
}
}
// --- Chunk interactions (#219 / #220) ---
function onSelectChunk(id: string): void {
const chunk = chunksStore.chunks.find((c) => c.id === id)
emit('nodeHighlight', chunk?.sourceNodeRef ?? null)
}
function onToggleSelect(id: string): void {
const next = new Set(selectedIds.value)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
selectedIds.value = next
}
let saveTimer: ReturnType<typeof setTimeout> | null = null
function onTextChange(id: string, text: string): void {
if (saveTimer) clearTimeout(saveTimer)
savingId.value = id
saveTimer = setTimeout(async () => {
await chunksStore.updateText(props.docId, id, text)
savingId.value = null
saveTimer = null
}, 600)
}
async function onTitleChange(id: string, title: string): Promise<void> {
await chunksStore.updateTitle(props.docId, id, title)
}
async function onMerge(id: string, dir: 'prev' | 'next'): Promise<void> {
const idx = chunksStore.chunks.findIndex((c) => c.id === id)
if (idx === -1) return
const other = dir === 'prev' ? chunksStore.chunks[idx - 1] : chunksStore.chunks[idx + 1]
if (!other) return
const ids = dir === 'prev' ? [other.id, id] : [id, other.id]
await chunksStore.merge(props.docId, ids)
}
async function onSplit(id: string, cursorOffset: number): Promise<void> {
await chunksStore.split(props.docId, id, cursorOffset)
}
async function onDrop(id: string): Promise<void> {
await chunksStore.drop(props.docId, id)
const next = new Set(selectedIds.value)
next.delete(id)
selectedIds.value = next
}
async function onAdd(afterId: string | undefined): Promise<void> {
await chunksStore.add(props.docId, '', afterId)
}
// --- Bulk actions (#220) ---
async function bulkDrop(): Promise<void> {
const ids = [...selectedIds.value]
for (const id of ids) await chunksStore.drop(props.docId, id)
selectedIds.value = new Set()
}
async function bulkMerge(): Promise<void> {
const ids = [...selectedIds.value]
if (ids.length < 2) return
const ordered = chunksStore.chunks.filter((c) => selectedIds.value.has(c.id)).map((c) => c.id)
await chunksStore.merge(props.docId, ordered)
selectedIds.value = new Set()
}
onMounted(() => {
chunksStore.load(props.docId)
})
</script>
<style scoped>
.chunks-editor {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.chunks-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
border-bottom: 1px solid var(--border);
background: var(--bg-surface);
flex-shrink: 0;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 12px;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 8px;
}
.chunk-count {
font-size: 12px;
font-family: 'IBM Plex Mono', monospace;
color: var(--text-muted);
}
.toolbar-saving {
font-size: 11px;
color: var(--accent);
}
.toolbar-btn {
padding: 4px 10px;
font-size: 12px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--text-secondary);
transition: all var(--transition);
}
.toolbar-btn:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.toolbar-btn.active {
background: var(--accent-muted);
border-color: var(--accent);
color: var(--accent);
}
.toolbar-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.toolbar-btn--primary {
background: var(--accent);
border-color: var(--accent);
color: white;
}
.toolbar-btn--primary:hover:not(:disabled) {
background: var(--accent-hover);
border-color: var(--accent-hover);
color: white;
}
/* Diff bar */
.diff-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 16px;
background: color-mix(in srgb, var(--warning) 5%, var(--bg-surface));
border-bottom: 1px solid var(--warning);
font-size: 12px;
flex-shrink: 0;
}
.diff-label {
color: var(--text-muted);
font-size: 11px;
}
.diff-store-select {
font-size: 12px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 2px 6px;
color: var(--text);
}
.diff-loading {
color: var(--text-muted);
}
.diff-counts {
display: flex;
gap: 8px;
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
}
.diff-added {
color: var(--success);
}
.diff-modified {
color: var(--warning);
}
.diff-removed {
color: var(--error);
}
/* Bulk bar */
.bulk-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--accent-muted);
border-bottom: 1px solid var(--accent);
flex-shrink: 0;
}
.bulk-count {
font-size: 12px;
color: var(--accent);
font-weight: 500;
flex: 1;
}
.bulk-btn {
padding: 3px 10px;
font-size: 12px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--text-secondary);
transition: all var(--transition);
}
.bulk-btn:hover:not(:disabled) {
border-color: var(--error);
color: var(--error);
}
.bulk-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.bulk-btn--cancel:hover {
border-color: var(--border);
color: var(--text-muted);
}
/* States */
.chunks-empty,
.chunks-loading {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
color: var(--text-muted);
font-size: 13px;
}
/* List */
.chunk-list {
flex: 1;
overflow-y: auto;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.add-chunk-end {
padding: 8px;
font-size: 12px;
color: var(--text-muted);
background: none;
border: 1px dashed var(--border-light);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition);
}
.add-chunk-end:hover {
border-color: var(--accent);
color: var(--accent);
}
/* Push modal */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px;
width: 380px;
max-width: 90vw;
display: flex;
flex-direction: column;
gap: 12px;
}
.modal-title {
font-size: 15px;
font-weight: 600;
color: var(--text);
}
.modal-label {
font-size: 12px;
color: var(--text-secondary);
}
.modal-input {
width: 100%;
font-size: 13px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 7px 10px;
color: var(--text);
outline: none;
transition: border-color var(--transition);
box-sizing: border-box;
}
.modal-input:focus {
border-color: var(--accent);
}
.push-summary {
font-size: 12px;
font-family: 'IBM Plex Mono', monospace;
color: var(--text-muted);
background: var(--bg-elevated);
border-radius: var(--radius-sm);
padding: 6px 10px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.modal-btn {
padding: 6px 14px;
font-size: 13px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition);
}
.modal-btn--cancel {
background: none;
border: 1px solid var(--border);
color: var(--text-secondary);
}
.modal-btn--cancel:hover {
border-color: var(--text-muted);
}
.modal-btn--primary {
background: var(--accent);
border: 1px solid var(--accent);
color: white;
}
.modal-btn--primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.modal-btn--primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.spinner {
width: 24px;
height: 24px;
border: 2px solid var(--border-light);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -7,6 +7,7 @@ import {
getPreviewUrl,
rechunkDocument,
pushDocumentToStore,
fetchDocumentTree,
} from './api'
vi.mock('../../shared/api/http', () => ({
@ -91,4 +92,14 @@ describe('document API', () => {
})
expect(result).toEqual({ jobId: 'job-2' })
})
it('fetchDocumentTree calls GET /api/documents/:id/tree', async () => {
const tree = [{ ref: '#/body', type: 'body', label: 'Body', children: [] }]
apiFetch.mockResolvedValue(tree)
const result = await fetchDocumentTree('42')
expect(apiFetch).toHaveBeenCalledWith('/api/documents/42/tree')
expect(result).toEqual(tree)
})
})

View file

@ -1,4 +1,4 @@
import type { Document } from '../../shared/types'
import type { Document, DocTreeNode } from '../../shared/types'
import { apiFetch } from '../../shared/api/http'
export function fetchDocuments(): Promise<Document[]> {
@ -37,3 +37,7 @@ export function pushDocumentToStore(id: string, store: string): Promise<{ jobId:
body: JSON.stringify({ store }),
})
}
export function fetchDocumentTree(id: string): Promise<DocTreeNode[]> {
return apiFetch<DocTreeNode[]>(`/api/documents/${id}/tree`)
}

View file

@ -0,0 +1,139 @@
<template>
<li class="tree-node" role="treeitem" :aria-expanded="hasChildren ? open : undefined">
<button
class="tree-node-row"
:class="{
'tree-node-row--selected': selected === node.ref,
'tree-node-row--highlight': highlight === node.ref,
}"
@click="onRowClick"
>
<span class="tree-node-indent" :style="{ width: `${depth * 12}px` }" />
<span v-if="hasChildren" class="tree-node-toggle" :class="{ open }" @click.stop="open = !open"
></span
>
<span v-else class="tree-node-toggle-placeholder" />
<span class="tree-node-type">{{ node.type }}</span>
<span class="tree-node-label" :title="node.label">{{ node.label }}</span>
</button>
<ul v-if="hasChildren && open" class="tree-list" role="group">
<DocTreeNode
v-for="child in node.children"
:key="child.ref"
:node="child"
:depth="depth + 1"
:selected="selected"
:highlight="highlight"
@select="$emit('select', $event)"
/>
</ul>
</li>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { DocTreeNode } from '../../../shared/types'
const props = withDefaults(
defineProps<{
node: DocTreeNode
depth?: number
selected?: string | null
highlight?: string | null
}>(),
{ depth: 0 },
)
const emit = defineEmits<{
select: [ref: string]
}>()
const open = ref(props.depth < 2)
const hasChildren = computed(() => props.node.children.length > 0)
function onRowClick(): void {
emit('select', props.node.ref)
}
</script>
<style scoped>
.tree-node {
list-style: none;
}
.tree-list {
list-style: none;
}
.tree-node-row {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
padding: 3px 8px 3px 4px;
background: none;
border: none;
cursor: pointer;
font-size: 12px;
color: var(--text-secondary);
text-align: left;
border-radius: 0;
transition: background var(--transition);
}
.tree-node-row:hover {
background: var(--bg-elevated);
color: var(--text);
}
.tree-node-row--selected {
background: var(--accent-muted);
color: var(--accent);
}
.tree-node-row--highlight {
background: var(--warning-muted, rgba(234, 179, 8, 0.1));
color: var(--text);
}
.tree-node-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
font-size: 12px;
color: var(--text-muted);
transform: rotate(0deg);
transition: transform 0.15s;
flex-shrink: 0;
}
.tree-node-toggle.open {
transform: rotate(90deg);
}
.tree-node-toggle-placeholder {
width: 14px;
flex-shrink: 0;
}
.tree-node-type {
font-family: 'IBM Plex Mono', monospace;
font-size: 10px;
color: var(--text-muted);
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 3px;
padding: 0 4px;
flex-shrink: 0;
white-space: nowrap;
}
.tree-node-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View file

@ -0,0 +1,107 @@
<template>
<div class="tree-rail" data-e2e="tree-rail">
<div v-if="loading" class="tree-rail-placeholder">
<span class="spinner" />
</div>
<div v-else-if="error" class="tree-rail-placeholder tree-rail-error">
<span>{{ error }}</span>
<button class="retry-btn" @click="$emit('reload')"></button>
</div>
<div v-else-if="!nodes.length" class="tree-rail-placeholder">
<span class="tree-rail-empty">{{ t('tree.empty') }}</span>
</div>
<ul v-else class="tree-list" role="tree">
<TreeNode
v-for="node in nodes"
:key="node.ref"
:node="node"
:selected="selected"
:highlight="highlight"
@select="(ref) => $emit('select', ref)"
/>
</ul>
</div>
</template>
<script setup lang="ts">
import type { DocTreeNode } from '../../../shared/types'
import { useI18n } from '../../../shared/i18n'
import TreeNode from './DocTreeNode.vue'
defineProps<{
nodes: DocTreeNode[]
loading?: boolean
error?: string | null
selected?: string | null
highlight?: string | null
}>()
defineEmits<{
select: [ref: string]
reload: []
}>()
const { t } = useI18n()
</script>
<style scoped>
.tree-rail {
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
background: var(--bg-surface);
border-right: 1px solid var(--border);
}
.tree-rail-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
height: 100%;
padding: 16px;
color: var(--text-muted);
font-size: 13px;
}
.tree-rail-error {
color: var(--error);
}
.tree-list {
list-style: none;
padding: 8px 0;
}
.retry-btn {
background: none;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 2px 8px;
cursor: pointer;
font-size: 13px;
color: var(--text-secondary);
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border-light);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.tree-rail-empty {
font-size: 12px;
color: var(--text-muted);
}
</style>

View file

@ -0,0 +1,123 @@
<template>
<header class="workspace-header" data-e2e="workspace-header">
<div class="workspace-header-main">
<svg class="doc-icon" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z"
clip-rule="evenodd"
/>
</svg>
<h1 class="workspace-title" :title="doc.filename">{{ doc.filename }}</h1>
<StatusBadge :state="doc.lifecycleState" />
</div>
<div class="workspace-header-meta">
<div v-if="doc.stores?.length" class="workspace-stores">
<RouterLink
v-for="store in doc.stores"
:key="store"
:to="{ name: ROUTES.STORE_DETAIL, params: { store } }"
class="store-chip"
:title="store"
>
{{ store }}
</RouterLink>
</div>
<span v-if="doc.fileSize" class="meta-item">{{ formatSize(doc.fileSize) }}</span>
<span class="meta-item">{{ formatRelativeTime(doc.lifecycleStateAt ?? doc.createdAt) }}</span>
</div>
</header>
</template>
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import type { Document } from '../../../shared/types'
import { formatSize, formatRelativeTime } from '../../../shared/format'
import { ROUTES } from '../../../shared/routing/names'
import StatusBadge from './StatusBadge.vue'
defineProps<{
doc: Document
}>()
</script>
<style scoped>
.workspace-header {
position: sticky;
top: 0;
z-index: 10;
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
padding: 12px 20px;
display: flex;
flex-direction: column;
gap: 6px;
}
.workspace-header-main {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.doc-icon {
width: 16px;
height: 16px;
color: var(--accent);
flex-shrink: 0;
}
.workspace-title {
font-size: 15px;
font-weight: 600;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.workspace-header-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.workspace-stores {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.store-chip {
display: inline-flex;
align-items: center;
padding: 1px 8px;
font-size: 11px;
font-family: 'IBM Plex Mono', monospace;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 100px;
color: var(--text-secondary);
text-decoration: none;
transition: all var(--transition);
white-space: nowrap;
overflow: hidden;
max-width: 120px;
text-overflow: ellipsis;
}
.store-chip:hover {
border-color: var(--accent);
color: var(--accent);
}
.meta-item {
font-size: 11px;
color: var(--text-muted);
font-family: 'IBM Plex Mono', monospace;
}
</style>

View file

@ -0,0 +1,44 @@
<template>
<div class="ask-tab" data-e2e="ask-tab">
<div class="coming-soon">
<p class="coming-soon-title">{{ t('workspace.askComingSoon') }}</p>
<p class="coming-soon-sub">{{ t('workspace.askComingSoonHint') }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from '../shared/i18n'
defineProps<{ docId: string }>()
const { t } = useI18n()
</script>
<style scoped>
.ask-tab {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
}
.coming-soon {
text-align: center;
display: flex;
flex-direction: column;
gap: 8px;
}
.coming-soon-title {
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
}
.coming-soon-sub {
font-size: 12px;
color: var(--text-muted);
}
</style>

View file

@ -0,0 +1,132 @@
<template>
<div class="chunks-tab" data-e2e="chunks-tab">
<!-- Tree rail -->
<div class="tree-pane" :class="{ 'tree-pane--collapsed': treeCollapsed }">
<button
class="tree-toggle"
:title="treeCollapsed ? t('tree.expand') : t('tree.collapse')"
@click="treeCollapsed = !treeCollapsed"
>
<span class="tree-toggle-icon" :class="{ 'tree-toggle-icon--collapsed': treeCollapsed }"
></span
>
</button>
<DocTreeRail
v-if="!treeCollapsed"
:nodes="treeNodes"
:loading="treeLoading"
:error="treeError"
:selected="selectedNodeRef"
:highlight="highlightRef"
@select="onTreeSelect"
@reload="loadTree"
/>
</div>
<!-- Chunks editor -->
<div class="editor-pane">
<ChunksEditor
:doc-id="docId"
:available-stores="availableStores"
@node-highlight="highlightRef = $event"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import type { DocTreeNode } from '../shared/types'
import { fetchDocumentTree } from '../features/document/api'
import DocTreeRail from '../features/document/ui/DocTreeRail.vue'
import ChunksEditor from '../features/chunks/ui/ChunksEditor.vue'
import { useI18n } from '../shared/i18n'
const props = defineProps<{
docId: string
availableStores: string[]
}>()
const { t } = useI18n()
const treeNodes = ref<DocTreeNode[]>([])
const treeLoading = ref(false)
const treeError = ref<string | null>(null)
const treeCollapsed = ref(false)
const selectedNodeRef = ref<string | null>(null)
const highlightRef = ref<string | null>(null)
async function loadTree(): Promise<void> {
treeLoading.value = true
treeError.value = null
try {
treeNodes.value = await fetchDocumentTree(props.docId)
} catch (e) {
treeError.value = (e as Error).message || 'Failed to load tree'
} finally {
treeLoading.value = false
}
}
function onTreeSelect(ref: string): void {
selectedNodeRef.value = ref
highlightRef.value = ref
}
onMounted(loadTree)
</script>
<style scoped>
.chunks-tab {
display: flex;
height: 100%;
overflow: hidden;
}
.tree-pane {
width: 240px;
flex-shrink: 0;
display: flex;
position: relative;
transition: width 0.2s;
}
.tree-pane--collapsed {
width: 24px;
}
.tree-toggle {
position: absolute;
top: 12px;
right: -1px;
z-index: 1;
width: 18px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-surface);
border: 1px solid var(--border);
border-left: none;
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
cursor: pointer;
color: var(--text-muted);
font-size: 14px;
}
.tree-toggle-icon {
transition: transform 0.2s;
}
.tree-toggle-icon--collapsed {
transform: rotate(180deg);
}
.editor-pane {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
</style>

View file

@ -0,0 +1,44 @@
<template>
<div class="inspect-tab" data-e2e="inspect-tab">
<div class="coming-soon">
<p class="coming-soon-title">{{ t('workspace.inspectComingSoon') }}</p>
<p class="coming-soon-sub">{{ t('workspace.inspectComingSoonHint') }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from '../shared/i18n'
defineProps<{ docId: string }>()
const { t } = useI18n()
</script>
<style scoped>
.inspect-tab {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
}
.coming-soon {
text-align: center;
display: flex;
flex-direction: column;
gap: 8px;
}
.coming-soon-title {
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
}
.coming-soon-sub {
font-size: 12px;
color: var(--text-muted);
}
</style>

View file

@ -1,45 +1,233 @@
<template>
<ComingSoonShell
:title="t('comingSoon.title')"
:subtitle="t('comingSoon.subtitle.docWorkspace')"
:hint="hint"
/>
<div class="workspace-page">
<!-- Loading doc -->
<div v-if="loadingDoc" class="workspace-loading">
<span class="spinner" />
</div>
<!-- Doc error -->
<div v-else-if="docError" class="workspace-error">
<p>{{ docError }}</p>
<RouterLink :to="{ name: ROUTES.DOCS_LIBRARY }" class="back-link">
{{ t('workspace.backToLibrary') }}
</RouterLink>
</div>
<template v-else-if="doc">
<!-- Sticky header (#218) -->
<DocWorkspaceHeader :doc="doc" />
<!-- Tab strip (#216) -->
<div class="tab-strip" role="tablist" data-e2e="tab-strip">
<button
v-for="m in ALL_MODES"
:key="m"
class="tab-btn"
:class="{ active: activeMode === m, disabled: !modeEnabled(m) }"
role="tab"
:aria-selected="activeMode === m"
:disabled="!modeEnabled(m)"
:title="!modeEnabled(m) ? t('workspace.modeDisabled') : undefined"
@click="switchMode(m)"
>
{{ t(`workspace.tabs.${m}`) }}
</button>
</div>
<!-- Tab content lazy loaded (#216) -->
<div class="tab-content" role="tabpanel" data-e2e="tab-content">
<Suspense>
<DocChunksTab
v-if="activeMode === 'chunks'"
:doc-id="id"
:available-stores="doc.stores ?? []"
/>
<DocInspectTab v-else-if="activeMode === 'inspect'" :doc-id="id" />
<DocAskTab v-else-if="activeMode === 'ask'" :doc-id="id" />
</Suspense>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { RouterLink, useRouter, useRoute } from 'vue-router'
import type { Document } from '../shared/types'
import { fetchDocument } from '../features/document/api'
import { useFeatureFlagStore } from '../features/feature-flags/store'
import { ALL_MODES, type DocMode } from '../shared/routing/modes'
import { resolveMode } from '../shared/routing/resolveMode'
import { useCrumbs } from '../shared/breadcrumb/store'
import { truncate } from '../shared/breadcrumb/text'
import type { Crumb } from '../shared/breadcrumb/types'
import { useI18n } from '../shared/i18n'
import { type DocMode } from '../shared/routing/modes'
import { ROUTES } from '../shared/routing/names'
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
import DocWorkspaceHeader from '../features/document/ui/DocWorkspaceHeader.vue'
import DocChunksTab from './DocChunksTab.vue'
import DocInspectTab from './DocInspectTab.vue'
import DocAskTab from './DocAskTab.vue'
/**
* Doc workspace placeholder. Receives the doc id (from `:id` path param)
* and the resolved mode (from `?mode=` query param, parsed by the
* router via `parseMode`). The full workspace is built by #216 (E4)
* on top of #218-#224 (E5 chunks editor).
*/
const props = defineProps<{ id: string; mode: DocMode }>()
const router = useRouter()
const route = useRoute()
const { t } = useI18n()
const flagStore = useFeatureFlagStore()
const hint = computed(() => t('comingSoon.hint.docWorkspace', { id: props.id, mode: props.mode }))
const doc = ref<Document | null>(null)
const loadingDoc = ref(true)
const docError = ref<string | null>(null)
const activeMode = ref<DocMode>(props.mode)
// Provide the breadcrumb segments. Once the doc is fetched (E4 / #216),
// `truncate(doc.filename, 40)` will replace the id; for now we render
// the id itself so the user can still see what they are looking at.
const crumbs = computed<Crumb[]>(() => [
{ kind: 'link', label: t('breadcrumb.studio'), to: { name: ROUTES.HOME } },
{
kind: 'link',
label: truncate(props.id, 40),
label: doc.value ? truncate(doc.value.filename, 40) : truncate(props.id, 40),
to: { name: ROUTES.DOC_WORKSPACE, params: { id: props.id } },
},
{ kind: 'leaf', label: t(`breadcrumb.mode.${props.mode}`) },
{ kind: 'leaf', label: t(`breadcrumb.mode.${activeMode.value}`) },
])
useCrumbs(crumbs)
function modeEnabled(m: DocMode): boolean {
return flagStore.modeFlags()[m]
}
function switchMode(m: DocMode): void {
if (!modeEnabled(m)) return
activeMode.value = m
router.replace({ query: { ...route.query, mode: m } })
}
async function loadDoc(): Promise<void> {
loadingDoc.value = true
docError.value = null
try {
doc.value = await fetchDocument(props.id)
} catch (e) {
docError.value = (e as Error).message || 'Failed to load document'
} finally {
loadingDoc.value = false
}
}
onMounted(async () => {
await flagStore.load()
const flags = flagStore.modeFlags()
const resolved = resolveMode(props.mode, flags)
if (!resolved) {
router.replace({ name: ROUTES.DOCS_LIBRARY, query: { reason: 'no-mode-enabled' } })
return
}
if (resolved !== props.mode) {
router.replace({ query: { ...route.query, mode: resolved } })
}
activeMode.value = resolved
await loadDoc()
})
watch(
() => props.mode,
(m) => {
const flags = flagStore.modeFlags()
const resolved = resolveMode(m, flags)
if (resolved) activeMode.value = resolved
},
)
</script>
<style scoped>
.workspace-page {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.workspace-loading,
.workspace-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 12px;
color: var(--text-muted);
font-size: 13px;
}
.workspace-error {
color: var(--error);
}
.back-link {
font-size: 13px;
color: var(--text-secondary);
text-decoration: none;
}
.back-link:hover {
color: var(--text);
}
.tab-strip {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border);
background: var(--bg-surface);
padding: 0 20px;
flex-shrink: 0;
}
.tab-btn {
padding: 8px 16px;
font-size: 13px;
font-weight: 500;
color: var(--text-muted);
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: all var(--transition);
margin-bottom: -1px;
}
.tab-btn:hover:not(.disabled) {
color: var(--text);
}
.tab-btn.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.tab-btn.disabled {
opacity: 0.35;
cursor: not-allowed;
}
.tab-content {
flex: 1;
overflow: hidden;
}
.spinner {
width: 28px;
height: 28px;
border: 2px solid var(--border-light);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -361,6 +361,53 @@ const messages: Messages = {
'settings.about': '\u00C0 propos',
'settings.designArticle': 'Comment Docling Studio a \u00e9t\u00e9 con\u00e7u',
// Doc workspace (#216, #218)
'workspace.tabs.ask': 'Ask',
'workspace.tabs.inspect': 'Inspect',
'workspace.tabs.chunks': 'Chunks',
'workspace.backToLibrary': 'Retour \u00e0 la biblioth\u00e8que',
'workspace.modeDisabled': 'Mode d\u00e9sactiv\u00e9 pour ce d\u00e9ploiement',
'workspace.inspectComingSoon': 'Inspect \u2014 disponible en 0.7.0',
'workspace.inspectComingSoonHint': 'La vue arborescence + bbox arrive prochainement.',
'workspace.askComingSoon': 'Ask \u2014 disponible en 0.7.0',
'workspace.askComingSoonHint':
'Le raisonnement agentique sur le document arrive prochainement.',
// Doc tree rail (#217)
'tree.empty': "Aucun n\u0153ud dans l'arbre.",
'tree.expand': 'D\u00e9velopper',
'tree.collapse': 'R\u00e9duire',
// Chunks editor (#219, #220, #221, #222)
'chunks.count': '{n} chunk(s)',
'chunks.empty': "Aucun chunk \u2014 parsez le document d'abord.",
'chunks.saving': 'Sauvegarde\u2026',
'chunks.noTitle': '(sans titre)',
'chunks.editPlaceholder': 'Texte du chunk\u2026',
'chunks.mergePrev': 'Fusionner avec le pr\u00e9c\u00e9dent',
'chunks.mergeNext': 'Fusionner avec le suivant',
'chunks.split': 'Scinder au curseur',
'chunks.drop': 'Supprimer',
'chunks.add': 'Ajouter un chunk',
'chunks.retitle': 'Renommer',
'chunks.diffToggle': 'Afficher le diff',
'chunks.diffClose': 'Fermer le diff',
'chunks.diffRef': 'R\u00e9f\u00e9rence',
'chunks.diffAdded': 'ajout\u00e9(s)',
'chunks.diffModified': 'modifi\u00e9(s)',
'chunks.diffRemoved': 'supprim\u00e9(s)',
'chunks.pushTitle': 'Pousser vers un store',
'chunks.pushStore': 'Store cible',
'chunks.pushPlaceholder': 'Nom du store\u2026',
'chunks.pushSummary': '{embeds} chunks \u00b7 ~{tokens} tokens',
'chunks.pushConfirm': 'Pousser',
'chunks.pushCancel': 'Annuler',
'chunks.pushedJob': 'Job lanc\u00e9 : {jobId}',
'chunks.selected': '{n} s\u00e9lectionn\u00e9(s)',
'chunks.bulkDrop': 'Supprimer la s\u00e9lection',
'chunks.bulkMerge': 'Fusionner la s\u00e9lection',
'chunks.bulkCancel': 'Annuler',
// Disclaimer
'disclaimer.banner':
'Instance de d\u00e9monstration \u2014 les documents upload\u00e9s sont partag\u00e9s et temporaires (max {n} Mo). Ne pas envoyer de fichiers confidentiels.',
@ -705,6 +752,52 @@ const messages: Messages = {
'settings.about': 'About',
'settings.designArticle': 'How Docling Studio was designed',
// Doc workspace (#216, #218)
'workspace.tabs.ask': 'Ask',
'workspace.tabs.inspect': 'Inspect',
'workspace.tabs.chunks': 'Chunks',
'workspace.backToLibrary': 'Back to library',
'workspace.modeDisabled': 'Mode disabled for this deployment',
'workspace.inspectComingSoon': 'Inspect \u2014 coming in 0.7.0',
'workspace.inspectComingSoonHint': 'Tree + bbox view coming soon.',
'workspace.askComingSoon': 'Ask \u2014 coming in 0.7.0',
'workspace.askComingSoonHint': 'Agentic reasoning over the document coming soon.',
// Doc tree rail (#217)
'tree.empty': 'No nodes in tree.',
'tree.expand': 'Expand',
'tree.collapse': 'Collapse',
// Chunks editor (#219, #220, #221, #222)
'chunks.count': '{n} chunk(s)',
'chunks.empty': 'No chunks yet \u2014 parse the document first.',
'chunks.saving': 'Saving\u2026',
'chunks.noTitle': '(untitled)',
'chunks.editPlaceholder': 'Chunk text\u2026',
'chunks.mergePrev': 'Merge with previous',
'chunks.mergeNext': 'Merge with next',
'chunks.split': 'Split at cursor',
'chunks.drop': 'Drop',
'chunks.add': 'Add chunk',
'chunks.retitle': 'Rename',
'chunks.diffToggle': 'Show diff',
'chunks.diffClose': 'Close diff',
'chunks.diffRef': 'Reference',
'chunks.diffAdded': 'added',
'chunks.diffModified': 'modified',
'chunks.diffRemoved': 'removed',
'chunks.pushTitle': 'Push to store',
'chunks.pushStore': 'Target store',
'chunks.pushPlaceholder': 'Store name\u2026',
'chunks.pushSummary': '{embeds} chunks \u00b7 ~{tokens} tokens',
'chunks.pushConfirm': 'Push',
'chunks.pushCancel': 'Cancel',
'chunks.pushedJob': 'Job dispatched: {jobId}',
'chunks.selected': '{n} selected',
'chunks.bulkDrop': 'Drop selected',
'chunks.bulkMerge': 'Merge selected',
'chunks.bulkCancel': 'Cancel',
// Disclaimer
'disclaimer.banner':
'Demo instance \u2014 uploaded documents are shared and temporary (max {n} MB). Do not upload confidential files.',

View file

@ -132,3 +132,41 @@ export interface Rect {
export type Locale = 'fr' | 'en'
export type Theme = 'dark' | 'light'
// ---------------------------------------------------------------------------
// E4/E5 — Doc workspace tree + chunks (#216#222)
// ---------------------------------------------------------------------------
/** Node in the parsed document tree returned by GET /api/documents/:id/tree */
export interface DocTreeNode {
ref: string
type: string
label: string
children: DocTreeNode[]
}
/** Doc-centric chunk (distinct from legacy analysis Chunk). */
export interface DocChunk {
id: string
docId: string
text: string
title?: string
sourceNodeRef?: string
pageRange?: [number, number]
tokenCount?: number
createdAt: string
updatedAt: string
}
export type ChunkDiffStatus = 'added' | 'modified' | 'removed' | 'unchanged'
export interface ChunkDiff {
chunkId: string
status: ChunkDiffStatus
textDiff?: string
}
export interface PushSummary {
embeds: number
tokens: number
}