diff --git a/client-v3/e2e/tests/10-show-config-script.spec.ts b/client-v3/e2e/tests/10-show-config-script.spec.ts
index 068f8b80..d1a4c1b4 100644
--- a/client-v3/e2e/tests/10-show-config-script.spec.ts
+++ b/client-v3/e2e/tests/10-show-config-script.spec.ts
@@ -393,3 +393,79 @@ test('deletes the cue', async () => {
// After deletion only the add-cue-btn remains; no actual cue buttons should exist
await expect(page.locator('.cue-button:not(.add-cue-btn)')).not.toBeVisible({ timeout: 5_000 });
});
+
+// ── Line part removal ──────────────────────────────────────────────────────
+
+test('remove button is absent on a single-part dialogue line', async () => {
+ await page.goto(`${UI_BASE}/show-config/script`);
+ await waitForAppReady(page);
+ await page.getByRole('button', { name: 'Edit', exact: true }).click();
+ await expect(page.locator('button:has-text("Stop Editing")')).toBeVisible({ timeout: 10_000 });
+
+ await page.click('button:has-text("Add Dialogue")');
+ const lineEditor = page
+ .locator('div.row')
+ .filter({ has: page.locator('button:has-text("Done")') })
+ .first();
+ await expect(lineEditor.locator('button:has-text("Done")')).toBeVisible({ timeout: 5_000 });
+
+ // With only 1 part no remove button should be present
+ await expect(lineEditor.locator('button.btn-outline-danger')).not.toBeVisible();
+});
+
+test('remove button appears when a second part is added', async () => {
+ const lineEditor = page
+ .locator('div.row')
+ .filter({ has: page.locator('button:has-text("Done")') })
+ .first();
+
+ // btn-secondary is the add-line-part (+) button — only btn-secondary in the editor row
+ await lineEditor.locator('button.btn-secondary').click();
+
+ // One remove button per part
+ await expect(lineEditor.locator('button.btn-outline-danger')).toHaveCount(2, { timeout: 3_000 });
+});
+
+test('clicking remove reduces to single part and hides the remove button', async () => {
+ const lineEditor = page
+ .locator('div.row')
+ .filter({ has: page.locator('button:has-text("Done")') })
+ .first();
+
+ await lineEditor.locator('button.btn-outline-danger').first().click();
+
+ // Back to 1 part — remove button gone
+ await expect(lineEditor.locator('button.btn-outline-danger')).not.toBeVisible({ timeout: 3_000 });
+});
+
+test('dialogue line with removed part saves correctly', async () => {
+ const lineEditor = page
+ .locator('div.row')
+ .filter({ has: page.locator('button:has-text("Done")') })
+ .first();
+
+ await lineEditor.locator('select').nth(0).selectOption({ label: 'Act 1' });
+ await lineEditor.locator('select').nth(1).selectOption({ label: 'Scene 1' });
+ await lineEditor
+ .locator('select')
+ .filter({ hasText: 'Hamlet' })
+ .selectOption({ label: 'Hamlet' });
+ await lineEditor.locator('input[type="text"]').last().fill('Surviving part text');
+
+ await lineEditor.locator('button:has-text("Done")').click();
+ // Delete the auto-added blank editor that appears after Done
+ await page.locator('button.btn-danger:has-text("Delete")').first().click();
+ await expect(page.locator('button:has-text("Done")')).not.toBeVisible({ timeout: 5_000 });
+
+ await page.getByRole('button', { name: 'Save', exact: true }).click();
+ await waitForModal(page, 'Saving Script');
+ await expect(page.locator('.modal.show').getByText('Finished saving script.')).toBeVisible({
+ timeout: 15_000,
+ });
+ await confirmModal(page);
+ await waitForModalClosed(page);
+
+ await expect(
+ page.locator('.viewable-line').filter({ hasText: 'Surviving part text' })
+ ).toBeVisible({ timeout: 10_000 });
+});
diff --git a/client-v3/src/components/show/config/script/ScriptLineEditor.vue b/client-v3/src/components/show/config/script/ScriptLineEditor.vue
index af36a7ec..f6eea110 100644
--- a/client-v3/src/components/show/config/script/ScriptLineEditor.vue
+++ b/client-v3/src/components/show/config/script/ScriptLineEditor.vue
@@ -50,6 +50,7 @@
scriptMode === 1
"
:enable-add-button="state.line_parts.length < 4 && lineType === LINE_TYPES.DIALOGUE"
+ :show-remove-button="state.line_parts.length > 1 && lineType === LINE_TYPES.DIALOGUE"
:line-type="lineType"
:line-parts="state.line_parts"
:stage-direction-styles="
@@ -58,6 +59,7 @@
:stage-direction-style-id="state.stage_direction_style_id"
@update:model-value="onPartUpdate(index, $event)"
@add-line-part="addLinePart"
+ @remove-line-part="removeLinePart(index)"
@try-finish-line="tryFinishLine"
@stage-direction-style-change="onStageDirectionStyleChange"
/>
@@ -210,6 +212,13 @@ function onPartUpdate(index: number, part: ScriptLinePartType): void {
onStateChange();
}
+function removeLinePart(index: number): void {
+ state.value.line_parts = state.value.line_parts
+ .filter((_, i) => i !== index)
+ .map((part, i) => ({ ...part, part_index: i }));
+ onStateChange();
+}
+
function addLinePart(): void {
const blank = blankLinePart();
blank.part_index = state.value.line_parts.length;
diff --git a/client-v3/src/components/show/config/script/ScriptLinePart.vue b/client-v3/src/components/show/config/script/ScriptLinePart.vue
index aeb88a22..b5c99d42 100644
--- a/client-v3/src/components/show/config/script/ScriptLinePart.vue
+++ b/client-v3/src/components/show/config/script/ScriptLinePart.vue
@@ -47,6 +47,16 @@
/>
+
+
+
+
+
@@ -94,6 +104,7 @@ const props = defineProps<{
characterGroups: CharacterGroup[];
showAddButton: boolean;
enableAddButton: boolean;
+ showRemoveButton: boolean;
lineType: number;
lineParts: ScriptLinePart[];
stageDirectionStyles?: StageDirectionStyle[];
@@ -104,6 +115,7 @@ const props = defineProps<{
const emit = defineEmits<{
'update:modelValue': [part: ScriptLinePart];
'add-line-part': [];
+ 'remove-line-part': [];
'try-finish-line': [];
'stage-direction-style-change': [id: number | null];
}>();
diff --git a/client/src/vue_components/show/config/script/ScriptLineEditor.vue b/client/src/vue_components/show/config/script/ScriptLineEditor.vue
index d879a627..fb66f979 100644
--- a/client/src/vue_components/show/config/script/ScriptLineEditor.vue
+++ b/client/src/vue_components/show/config/script/ScriptLineEditor.vue
@@ -53,6 +53,7 @@
CURRENT_SHOW.script_mode === 1
"
:enable-add-button="state.line_parts.length < 4 && lineType === LINE_TYPES.DIALOGUE"
+ :show-remove-button="state.line_parts.length > 1 && lineType === LINE_TYPES.DIALOGUE"
:line-type="lineType"
:line-parts="state.line_parts"
:stage-direction-styles="
@@ -61,6 +62,7 @@
:stage-direction-style-id="state.stage_direction_style_id"
@input="stateChange"
@addLinePart="addLinePart"
+ @removeLinePart="removeLinePart(index)"
@tryFinishLine="tryFinishLine"
@stage-direction-style-change="onStageDirectionStyleChange"
/>
@@ -374,6 +376,13 @@ export default defineComponent({
(this as any).$v.state.$touch();
this.$emit('input', (this as any).state);
},
+ removeLinePart(index: number): void {
+ const state = (this as any).state;
+ state.line_parts = state.line_parts
+ .filter((_: any, i: number) => i !== index)
+ .map((part: any, i: number) => ({ ...part, part_index: i }));
+ this.stateChange();
+ },
addLinePart(): void {
const state = (this as any).state;
const blankLine = JSON.parse(JSON.stringify(this.blankLinePartObj));
diff --git a/client/src/vue_components/show/config/script/ScriptLinePart.vue b/client/src/vue_components/show/config/script/ScriptLinePart.vue
index 71ed7315..b7901721 100644
--- a/client/src/vue_components/show/config/script/ScriptLinePart.vue
+++ b/client/src/vue_components/show/config/script/ScriptLinePart.vue
@@ -69,6 +69,16 @@
/>
+
+
+
+
+
@@ -129,6 +139,10 @@ export default defineComponent({
required: true,
type: Boolean,
},
+ showRemoveButton: {
+ required: true,
+ type: Boolean,
+ },
lineType: {
required: true,
type: Number,
@@ -271,6 +285,9 @@ export default defineComponent({
addLinePart(): void {
this.$emit('addLinePart');
},
+ removeLinePart(): void {
+ this.$emit('removeLinePart');
+ },
stateChange(): void {
(this as any).$v.state.$touch();
this.$emit('input', (this as any).state);