The translation system is a multi-layered architecture with three key components:
The translation system has sophisticated button state management and synced field handling, but there are some areas where button state may not be fully synchronized with operational state.
TranslationToolbar.ts// Lines 378-410
setTranslating(translating: boolean, progressText?: string): void {
if (!this.element) return;
const progress = this.element.querySelector(
'.review-translation-progress'
) as HTMLElement;
const translateBtn = this.element.querySelector(
'[data-action="translate-document"]'
) as HTMLButtonElement;
const translateSentenceBtn = this.element.querySelector(
'[data-action="translate-sentence"]'
) as HTMLButtonElement;
if (progress) {
progress.style.display = translating ? 'flex' : 'none';
if (progressText) {
const text = progress.querySelector(
'.review-translation-progress-text'
) as HTMLElement;
if (text) {
text.textContent = progressText;
}
}
}
if (translateBtn) {
translateBtn.disabled = translating; // <- Button disabled when translating
}
if (translateSentenceBtn) {
translateSentenceBtn.disabled = translating; // <- Button disabled when translating
}
}
Button Disabling Path:
TranslationController.translateDocument() or translateSentence()setTranslationBusy(true) (line 479 in TranslationController)stateStore.setTranslationState({ busy: true })onBusyChange?.(busy)toolbar.setTranslating(true)button.disabled = trueButton Re-enabling Path:
setTranslationBusy(false) (line 536 in TranslationController)stateStore.setTranslationState({ busy: false })onBusyChange?.(false)toolbar.setTranslating(false)button.disabled = falseIssue 1: Missing Error Handling in Progress Updates
setTranslationBusy(false) is in the finally block, so buttons should be re-enablednotifyProgress() or other state updates throw, buttons might get stuckIssue 2: Button Disabled State Not Persisted Across UI Component Recreations
TranslationView doesn’t track button disabled state independentlyIssue 3: Limited Button State Granularity
disabled state (translating boolean)types.ts (Lines 13-19)export type TranslationStatus =
| 'untranslated' // No translation yet
| 'auto-translated' // Automatically translated
| 'manual' // Manually translated
| 'edited' // Auto-translated then manually edited
| 'out-of-sync' // Source changed, translation outdated
| 'synced'; // Translation up to date
Location 1: TranslationView.ts (Line 601)
private getSentenceStatus(
sentenceId: string,
side: 'source' | 'target'
): string | null {
if (!this.document) return null;
const pairs = this.document.correspondenceMap.pairs.filter((pair) =>
side === 'source'
? pair.sourceId === sentenceId
: pair.targetId === sentenceId
);
if (pairs.length === 0) return 'untranslated';
// Return the most relevant status
const statuses = pairs.map((p) => p.status);
if (statuses.includes('out-of-sync')) return 'out-of-sync';
if (statuses.includes('manual')) return 'manual';
if (statuses.includes('edited')) return 'edited';
if (statuses.includes('auto-translated')) return 'auto-translated';
return 'synced'; // <- Default status if no other matches
}
Status Mapping Logic:
out-of-sync (highest priority)manualeditedauto-translatedsynced (default/fallback)Location: TranslationState.ts (Lines 319-327)
When source sentence content changes:
updateSentence(
sentenceId: string,
newContent: string,
isSource: boolean
): void {
if (!this.document) return;
const sentences = isSource
? this.document.sourceSentences
: this.document.targetSentences;
const sentence = sentences.find((s) => s.id === sentenceId);
if (sentence) {
const previous = sentence.content;
sentence.content = newContent;
sentence.hash = this.hashContent(newContent);
// Mark corresponding translations as out-of-sync
const pairs = this.document.correspondenceMap.pairs.filter((p) =>
isSource ? p.sourceId === sentenceId : p.targetId === sentenceId
);
pairs.forEach((pair) => {
pair.status = 'out-of-sync'; // <- Set to out-of-sync
pair.lastModified = Date.now();
});
this.document.metadata.lastModified = Date.now();
this.notifyListeners();
return;
}
}
Currently: 'synced' is the DEFAULT fallback status when:
out-of-sync, manual, edited, or auto-translatedIssue: There’s NO explicit mechanism to SET status to ‘synced’. It’s only returned as a fallback.
Expected Behavior: 'synced' should be set when:
Problem: This never happens! The status just defaults to ‘synced’ without active reconciliation.
Location: TranslationView.ts (Lines 465-576)
private createSentenceElement(
sentence: Sentence,
side: 'source' | 'target'
): HTMLElement {
const sentenceElement = createDiv('review-translation-sentence');
sentenceElement.dataset.sentenceId = sentence.id;
sentenceElement.dataset.side = side;
sentenceElement.tabIndex = -1;
// Get translation status and pair info
const status = this.getSentenceStatus(sentence.id, side);
const pairInfo = this.getSentencePairInfo(sentence.id, side);
if (status) {
sentenceElement.dataset.status = status;
// Add CSS class for styling based on status
toggleClass(
sentenceElement,
`review-translation-sentence-${status}`,
true
);
// ...
}
// ... Create content, status chip, indicators, spinners, error messages
}
Rendering includes:
data-status="synced"review-translation-sentence-syncedSynced Visual Indicators (from user guide):
Location: TranslationController.ts (Lines 940-1002)
/**
* Handle source segment edit - full segment level (PREFERRED)
* Called when user edits an entire segment via "Edit Segment" button
*/
private async handleSourceSegmentEdit(
elementId: string,
newContent: string
): Promise<void> {
try {
// 1. Update segment content with re-segmentation
this.translationModule.updateSegmentContent(
elementId,
newContent,
'source'
);
// 2. Save to persistent storage
this.translationModule.saveToStorageNow();
// 3. Refresh view
this.refreshViewFromState();
// 4. Auto-retranslate if enabled
if (this.config.translationModuleConfig.config.autoTranslateOnEdit) {
// Get sentence IDs for error/loading tracking
const document = this.translationModule.getDocument();
const sentenceIds = document?.sourceSentences
.filter((s) => s.elementId === elementId)
.map((s) => s.id) ?? [];
if (sentenceIds.length > 0) {
// Mark as loading
this.clearSentenceErrors(sentenceIds);
this.markSentencesLoading(sentenceIds, true);
try {
// Translate all sentences in segment
for (const sentenceId of sentenceIds) {
await this.translationModule.translateSentence(sentenceId);
}
this.showNotification('Segment translated', 'success');
this.refreshViewFromState();
this.clearSentenceErrors(sentenceIds);
} catch (error) {
// Handle translation errors
const message = error instanceof Error ? error.message : 'Failed';
this.markSentencesError(sentenceIds, message);
this.view?.setErrorBanner({
message,
onRetry: () => {
void this.handleSourceSegmentEdit(elementId, newContent);
},
});
} finally {
this.markSentencesLoading(sentenceIds, false);
}
}
}
} catch (error) {
logger.error('Failed to update source segment', error);
this.showNotification('Failed to update segment', 'error');
}
}
Edit Flow Steps:
editButton.addEventListener('click', () => {
void this.enableSegmentEdit(
sectionElement,
elementId,
sectionSentences,
side
);
});
module.getContent()enableSegmentEdit.save() functioneditorBridge.saveSegmentEdit()onSourceSegmentEdit() or onTargetSegmentEdit()translationModule.updateSegmentContent()view.loadDocument(document)Location: TranslationView.ts (Lines 1250-1268)
// Call the segment edit callback
try {
if (side === 'source' && this.callbacks.onSourceSegmentEdit) {
await this.callbacks.onSourceSegmentEdit(elementId, newContent);
} else if (
side === 'target' &&
this.callbacks.onTargetSegmentEdit
) {
await this.callbacks.onTargetSegmentEdit(elementId, newContent);
} else {
logger.warn('No callback registered for segment edit', {
side,
hasSourceCallback: Boolean(this.callbacks.onSourceSegmentEdit),
hasTargetCallback: Boolean(this.callbacks.onTargetSegmentEdit),
});
return false;
}
} catch (error) {
logger.error('Error in segment edit callback', {
error,
elementId,
side,
});
return false;
}
Location: TranslationController.ts (Lines 407-422)
this.view = new TranslationView(
{
showCorrespondenceLines: config.showCorrespondenceLines,
highlightOnHover: config.highlightOnHover,
},
{
// Segment-level editing (primary)
onSourceSegmentEdit: (elementId: string, content: string) =>
this.handleSourceSegmentEdit(elementId, content),
onTargetSegmentEdit: (elementId: string, content: string) =>
this.handleTargetSegmentEdit(elementId, content),
// Sentence-level editing (deprecated, kept for compatibility)
onSourceSentenceEdit: (sentenceId: string, content: string) =>
this.handleSourceSentenceEdit(sentenceId, content),
onTargetSentenceEdit: (sentenceId: string, content: string) =>
this.handleTargetSentenceEdit(sentenceId, content),
},
markdown,
this.editorBridge,
this.stateStore || undefined
);
Location: TranslationEditorBridge.ts
/**
* Initialize editor for an entire segment (element)
* This is the preferred method for segment-based editing
*/
async initializeSegmentEditor(
container: HTMLElement,
elementId: string,
content: string,
side: 'source' | 'target'
): Promise<void> {
this.currentElementId = elementId;
this.currentSegmentContent = content;
this.currentLanguage = side;
// Clear any sentence-level state
this.currentSentenceId = null;
const options: InitializeOptions = {
container,
content,
diffHighlights: this.editorConfig.showDiffHighlights ? [] : undefined,
elementType: 'Para',
onContentChange: (markdown: string) => {
// Track changes as user types
this.onEditorContentChange(markdown);
},
};
try {
await this.initialize(options);
logger.info('Segment editor initialized', {
elementId,
side,
contentLength: content.length,
});
} catch (error) {
logger.error('Failed to initialize segment editor', error);
throw error;
}
}
/**
* Save edited segment
* Returns true if content changed and was saved
*/
public saveSegmentEdit(
elementId: string,
newContent: string,
side: 'source' | 'target'
): boolean {
if (!this.currentElementId || this.currentElementId !== elementId) {
logger.warn('No matching segment editor active for save', {
currentElementId: this.currentElementId,
requestedElementId: elementId,
});
return false;
}
const editor = this.getEditor();
const module = this.getModule();
if (!editor || !module) {
logger.error('Editor not initialized');
return false;
}
// Check if content actually changed
if (this.currentSegmentContent === newContent) {
logger.debug('No content change detected');
return false;
}
// Validation passes - controller handles ChangesModule
logger.info('Segment edit validated', {
elementId,
side,
contentLength: newContent.length,
});
return true;
}
Location: UIState.ts (Lines 54-85)
export interface TranslationState {
/** Whether translation mode is active */
isActive: boolean;
/** Currently selected source sentence ID */
selectedSourceSentenceId: string | null;
/** Currently selected target sentence ID */
selectedTargetSentenceId: string | null;
/** Translation mode: manual entry or automatic translation */
mode: 'manual' | 'automatic';
/** Whether a translation operation is in progress */
busy: boolean;
/** Source language code */
sourceLanguage: string;
/** Target language code */
targetLanguage: string;
/** Currently active translation provider */
activeProvider: string;
/** Show correspondence lines between sentences */
showCorrespondenceLines: boolean;
/** Highlight corresponding sentences on hover */
highlightOnHover: boolean;
/** IDs of sentences currently being translated */
loadingSentences: Set<string>;
/** Error messages for sentences that failed translation */
sentenceErrors: Map<string, string>;
/** Current translation progress status */
progressStatus: {
phase: 'idle' | 'running' | 'success' | 'error';
message: string;
percent?: number;
} | null;
}
Location: TranslationController.ts
Progress Updates (Lines 887-901):
private notifyProgress(status: TranslationProgressStatus): void {
this.view?.setDocumentProgress(status);
this.config.onProgressUpdate?.(status);
// Update StateStore with progress status
if (this.stateStore) {
this.stateStore.setTranslationState({
progressStatus: {
phase: status.phase,
message: status.message,
percent: status.percent,
},
});
}
}
Busy State Updates (Lines 903-912):
private setTranslationBusy(busy: boolean): void {
this.config.onBusyChange?.(busy);
// Update StateStore with busy state
if (this.stateStore) {
this.stateStore.setTranslationState({
busy: busy,
});
}
}
Selection State Updates (Lines 737-750):
private selectSentence(sentenceId: string, side: 'source' | 'target'): void {
// Clear previous selection
this.clearSelection();
// Set selected
this.selectedSentence = { id: sentenceId, side };
// Update StateStore if available
if (this.stateStore) {
this.stateStore.setTranslationState({
selectedSourceSentenceId: side === 'source' ? sentenceId : null,
selectedTargetSentenceId: side === 'target' ? sentenceId : null,
});
}
// ...
}
Location: TranslationView.ts (Lines 116-124)
// Subscribe to StateStore translation state changes
if (this.stateStore) {
this.stateStoreUnsubscribe = this.stateStore.on<TranslationState>(
'translation:changed',
(state: Readonly<TranslationState>) => {
this.handleStateStoreUpdate(state);
}
);
}
Handling State Updates (Lines 130-178):
private handleStateStoreUpdate(state: Readonly<TranslationState>): void {
logger.debug('StateStore translation state updated in view', {
busy: state.busy,
progressPhase: state.progressStatus?.phase,
hasSelectedSource: !!state.selectedSourceSentenceId,
hasSelectedTarget: !!state.selectedTargetSentenceId,
});
// Update progress status if it changed
if (
state.progressStatus &&
(state.progressStatus.phase !== this.progressStatus?.phase ||
state.progressStatus.message !== this.progressStatus?.message ||
state.progressStatus.percent !== this.progressStatus?.percent)
) {
this.setDocumentProgress({
phase: state.progressStatus.phase,
message: state.progressStatus.message,
percent: state.progressStatus.percent,
});
}
// Update selected sentence if it changed
if (
state.selectedSourceSentenceId &&
state.selectedSourceSentenceId !== this.selectedSentence?.id
) {
this.selectedSentence = {
id: state.selectedSourceSentenceId,
side: 'source',
};
// Re-apply selection UI if already rendered
if (this.element) {
this.restoreSelection();
}
} else if (
state.selectedTargetSentenceId &&
state.selectedTargetSentenceId !== this.selectedSentence?.id
) {
this.selectedSentence = {
id: state.selectedTargetSentenceId,
side: 'target',
};
// Re-apply selection UI if already rendered
if (this.element) {
this.restoreSelection();
}
}
}
Problem:
'synced' status is only a DEFAULT fallback, not actively setLocation: TranslationState.ts doesn’t have a method to set status back to synced
Recommendation:
// Add to TranslationState.ts
setSentencePairSynced(pairId: string): void {
if (!this.document) return;
const pair = this.document.correspondenceMap.pairs.find(p => p.id === pairId);
if (pair && pair.status === 'out-of-sync') {
pair.status = 'synced';
pair.lastModified = Date.now();
this.document.metadata.lastModified = Date.now();
this.notifyListeners();
}
}
Problem:
Recommendation:
busy flagbusy state on mount/recreationProblem:
setTranslationBusy() fails, buttons stay disabled foreverRecommendation:
Problem:
disabled stateRecommendation:
Problem:
Recommendation:
Problem:
Recommendation:
| Aspect | Status | Issue Level |
|---|---|---|
| Button disabled on translation | ✓ Working | Low |
| Button re-enabled after completion | ✓ Working | Low |
| Button re-enabled on error | ✓ Working | Low |
| Synced status assignment | ✗ Missing | High |
| Synced status persistence | ✓ Rendered | Medium |
| Edit field callbacks | ✓ Implemented | Low |
| Error recovery | ~ Partial | Medium |
| State persistence on recreate | ✗ Missing | High |
| Button state per-operation | ✗ Missing | Medium |
User Action (Edit/Translate)
↓
TranslationView (UI Component)
↓
TranslationController (Coordinator)
↓
TranslationModule (Business Logic)
↓
TranslationState (State Management)
↓
TranslationDocument (Data Model)
├── sourceSentences[]
├── targetSentences[]
└── correspondenceMap
├── pairs[] (with status: 'out-of-sync' | 'synced' | etc.)
├── forwardMapping (source → target)
└── reverseMapping (target → source)
↓
StateStore (Global State)
├── busy (button disabled state)
├── progressStatus
├── selectedSourceSentenceId
└── selectedTargetSentenceId
↓
DOM/UI Update
├── Button disabled state
├── Progress bar
├── Status chips/indicators
└── Error messages
| File | Purpose | Key Methods |
|---|---|---|
TranslationView.ts |
Render sentences, manage editing | createSentenceElement(), enableSegmentEdit(), getSentenceStatus() |
TranslationController.ts |
Coordinate UI and module | handleSourceSegmentEdit(), handleTargetSegmentEdit(), setTranslationBusy() |
TranslationToolbar.ts |
Toolbar UI with buttons | setTranslating() |
TranslationEditorBridge.ts |
Milkdown editor integration | initializeSegmentEditor(), saveSegmentEdit() |
TranslationState.ts |
Translation document state | updateSentence(), addTranslationPair() |
UIState.ts |
UI state definitions | TranslationState interface |
types.ts |
Type definitions | TranslationStatus, TranslationPair |