Skip to content

Conversation

@rclarke0
Copy link
Contributor

Overview

When multiple liquids are loaded into the same well, our current behavior is to layer them on top of each other. This creates an unclear UI because the shared well is always highlighted.

This PR creates a third liquid by combining the liquids in one well into a command seperated list. The total volume is displayed.

Test Plan and Hands on Testing

  • wrote unit tests.
  • smoke tested with protocol in ticket RQA-4799

Changelog

Screen.Recording.2025-12-10.at.5.55.50.PM.mov

Review requests

  • I think combining liquid volume into total volume creates a confusing experience for the user

Risk assessment

  • high changes the logic to parse through liquid loads to display in deck setup
  • this creates a different behavior than in PD - which replaces the previous liquid if you load another one into the same well

@rclarke0 rclarke0 requested a review from a team as a code owner December 12, 2025 14:57
@rclarke0 rclarke0 requested review from jerader and removed request for a team December 12, 2025 14:57
@rclarke0 rclarke0 marked this pull request as draft December 12, 2025 14:58
@codecov
Copy link

codecov bot commented Dec 12, 2025

Codecov Report

❌ Patch coverage is 66.42857% with 47 lines in your changes missing coverage. Please review.
✅ Project coverage is 57.14%. Comparing base (e26b645) to head (8bf9d7b).
⚠️ Report is 2 commits behind head on edge.

Files with missing lines Patch % Lines
shared-data/js/helpers/parseProtocolCommands.ts 58.33% 20 Missing ⚠️
shared-data/js/helpers/getLabwareInfoByLiquidId.ts 87.69% 8 Missing ⚠️
...TextString/utils/commandText/getLoadCommandText.ts 0.00% 6 Missing ⚠️
...useCommandTextString/utils/getLiquidDisplayName.ts 14.28% 6 Missing ⚠️
...otocolVisualization/LabwareSlotContainer/index.tsx 0.00% 5 Missing ⚠️
...rc/molecules/LiquidDetailCard/LiquidDetailCard.tsx 75.00% 2 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             edge   #20359      +/-   ##
==========================================
+ Coverage   56.76%   57.14%   +0.37%     
==========================================
  Files        3658     3658              
  Lines      304224   305071     +847     
  Branches    42798    43566     +768     
==========================================
+ Hits       172698   174323    +1625     
+ Misses     131312   130532     -780     
- Partials      214      216       +2     
Flag Coverage Δ
app 46.37% <66.18%> (+<0.01%) ⬆️
protocol-designer 19.47% <0.71%> (-0.01%) ⬇️
step-generation 5.66% <1.42%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
shared-data/js/constants.ts 100.00% <100.00%> (ø)
...rc/molecules/LiquidDetailCard/LiquidDetailCard.tsx 69.37% <75.00%> (-0.19%) ⬇️
...otocolVisualization/LabwareSlotContainer/index.tsx 1.24% <0.00%> (-0.04%) ⬇️
...TextString/utils/commandText/getLoadCommandText.ts 61.58% <0.00%> (-2.17%) ⬇️
...useCommandTextString/utils/getLiquidDisplayName.ts 36.36% <14.28%> (-53.64%) ⬇️
shared-data/js/helpers/getLabwareInfoByLiquidId.ts 90.10% <87.69%> (-7.12%) ⬇️
shared-data/js/helpers/parseProtocolCommands.ts 62.10% <58.33%> (+39.08%) ⬆️

... and 73 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@rclarke0 rclarke0 requested a review from mjhuff December 12, 2025 15:07
Copy link
Contributor

@mjhuff mjhuff left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation looks good to me. The way we visualize liquid combos is more of a Design-level decision, and if we do this in PD, we should keep the pattern consistent anyway.

A few cleanup comments - nice work!


import type { LoadLiquidRunTimeCommand, RunTimeCommand } from '../../command'

export const consolidateSharedWells = (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
export const consolidateSharedWells = (
const consolidateSharedWells = (

Let's move this function beneath the major exported util, too.

Comment on lines 8 to 12
interface WellVolumeEntry {
labwareId: string
volume: number
liquidId: string
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We keep our type interfaces at module scope.

Comment on lines 16 to 29
for (const [liquidId, labwareArray] of Object.entries(
liquidsByIdForLabware
)) {
for (const labware of labwareArray) {
for (const [well, volume] of Object.entries(labware.volumeByWell)) {
if (!wellToLabwareVolumes[well]) wellToLabwareVolumes[well] = []
wellToLabwareVolumes[well].push({
labwareId: labware.labwareId,
volume,
liquidId,
})
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a bit tricky parsing nested for...const blocks. We generally use forEach and the JS helper methods, so this would perhaps be a bit more JS semantic...I think this is equivalent:

Object.entries(liquidsByIdForLabware).forEach(([liquidId, labwareArray]) => {
  labwareArray.forEach(labware => {
    Object.entries(labware.volumeByWell).forEach(([well, volume]) => {
      wellToLabwareVolumes[well] ??= []
      wellToLabwareVolumes[well].push({ labwareId: labware.labwareId, volume, liquidId })
    })
  })
})

Comment on lines 32 to 57
const overlappingWells = Object.entries(wellToLabwareVolumes).filter(
([, entries]) => entries.length > 1
)
const consolidatedWells: LabwareByLiquidId = {}

for (const [well, entries] of overlappingWells) {
let totalVolume = 0
let labwareIdName = ''
const liquidIds = entries.map(e => e.liquidId).sort()
const mixedLiquidId = `mixed-${liquidIds.join('-')}`

for (const entry of entries) {
totalVolume += entry.volume
labwareIdName = entry.labwareId
}
if (!consolidatedWells[mixedLiquidId]) {
consolidatedWells[mixedLiquidId] = [
{
labwareId: labwareIdName,
volumeByWell: {},
},
]
}
const existingEntry = consolidatedWells[mixedLiquidId][0]
existingEntry.volumeByWell[well] = totalVolume
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto here, this avoids the let bindings, too.

const overlappingWells = Object.entries(wellToLabwareVolumes).filter(
  ([, entries]) => entries.length > 1
)
const consolidatedWells: LabwareByLiquidId = {}

overlappingWells.forEach(([well, entries]) => {
  const totalVolume = entries.reduce((sum, entry) => sum + entry.volume, 0)
  const labwareIdName = entries[0].labwareId
  const liquidIds = entries.map(e => e.liquidId).sort()
  const mixedLiquidId = `mixed-${liquidIds.join('-')}`

  consolidatedWells[mixedLiquidId] ??= [{ labwareId: labwareIdName, volumeByWell: {} }]
  consolidatedWells[mixedLiquidId][0].volumeByWell[well] = totalVolume
})

Comment on lines 59 to 81
const cleanedOriginal: LabwareByLiquidId = {}

for (const [liquidId, labwareArray] of Object.entries(
liquidsByIdForLabware
)) {
for (const labware of labwareArray) {
const remainingWells: Record<string, number> = {}

for (const [well, volume] of Object.entries(labware.volumeByWell)) {
const isOverlapping = overlappingWells.some(([ow]) => ow === well)
if (!isOverlapping) {
remainingWells[well] = volume
}
}
if (Object.keys(remainingWells).length > 0) {
if (!cleanedOriginal[liquidId]) cleanedOriginal[liquidId] = []
cleanedOriginal[liquidId].push({
labwareId: labware.labwareId,
volumeByWell: remainingWells,
})
}
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const cleanedOriginal: LabwareByLiquidId = {}
const overlappingWellSet = new Set(overlappingWells.map(([well]) => well))

Object.entries(liquidsByIdForLabware).forEach(([liquidId, labwareArray]) => {
  labwareArray.forEach(labware => {
    const remainingWells = Object.fromEntries(
      Object.entries(labware.volumeByWell).filter(([well]) => !overlappingWellSet.has(well))
    )
    
    if (Object.keys(remainingWells).length > 0) {
      cleanedOriginal[liquidId] ??= []
      cleanedOriginal[liquidId].push({
        labwareId: labware.labwareId,
        volumeByWell: remainingWells,
      })
    }
  })
})

}>
}

// Updated getLabwareInfoByLiquidId
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Updated getLabwareInfoByLiquidId

if (!seenLiquids.has(liquidId)) {
seenLiquids.add(liquidId)
const liquid = loadedLiquids.find(l => l.id === liquidId)
if (liquid) acc.push(liquid)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (liquid) acc.push(liquid)
if (liquid) {
acc.push(liquid)
}


mixedMap.set(key, {
id: mixedId,
displayColor: '#737578',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
displayColor: '#737578',
displayColor: MIXED_WELL_COLOR,

Yeah, think we should mimic PD here and pull this into a constant.

mixedMap.set(key, {
id: mixedId,
displayColor: '#737578',
displayName: `${liquidNames.length} liquids`,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This string has to be i18n'd, and we can't i18n in shared-data, so we should return something like a totalLiquids here and then have the app take that value and do a proper "{{totalLiquids}} liquids" translation.

@rclarke0 rclarke0 marked this pull request as ready for review December 15, 2025 16:47
@rclarke0 rclarke0 requested a review from a team as a code owner December 15, 2025 16:48
@rclarke0 rclarke0 requested a review from mjhuff December 15, 2025 16:48
Copy link
Contributor

@mjhuff mjhuff left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coming along! I think the abstraction is getting a bit leaky by making displayName return a number though, see comment. Also, it seems the i18n keys aren't quite in the right spot:

Screenshot 2025-12-15 at 12 34 07 PM

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this file intentionally targeted? I think we need protocol_setup for sure, but maybe want it here too?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto

mixedMap.set(key, {
id: mixedId,
displayColor: MIXED_WELL_COLOR,
displayName: totalLiquids,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should avoid assigning something to a parameter called displayName that isn't the actual displayName. Maybe what we can do is return null for displayName for multiple liquids, and now return a totalLiquids property.

Then, where ever we do this name rendering, we can either check the totalLiquids and displayName properties and do our rendering based on that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants